diff --git a/releasenotes/notes/add_designate_zones_support-35fa9b8b09995b43.yaml b/releasenotes/notes/add_designate_zones_support-35fa9b8b09995b43.yaml new file mode 100644 index 000000000..f5253af0f --- /dev/null +++ b/releasenotes/notes/add_designate_zones_support-35fa9b8b09995b43.yaml @@ -0,0 +1,4 @@ +--- +features: + - Add support for Designate zones resources, with the + usual methods (search/list/get/create/update/delete). diff --git a/requirements.txt b/requirements.txt index bbe0083b1..23ead37c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,5 +19,6 @@ python-troveclient>=1.2.0 python-ironicclient>=0.10.0 python-swiftclient>=2.5.0 python-heatclient>=0.3.0 +python-designateclient>=2.1.0 dogpile.cache>=0.5.3 diff --git a/shade/_tasks.py b/shade/_tasks.py index 1ada75a21..a018612b5 100644 --- a/shade/_tasks.py +++ b/shade/_tasks.py @@ -772,3 +772,23 @@ class StackDelete(task_manager.Task): class StackGet(task_manager.Task): def main(self, client): return client.heat_client.stacks.get(**self.args) + + +class ZoneList(task_manager.Task): + def main(self, client): + return client.designate_client.zones.list() + + +class ZoneCreate(task_manager.Task): + def main(self, client): + return client.designate_client.zones.create(**self.args) + + +class ZoneUpdate(task_manager.Task): + def main(self, client): + return client.designate_client.zones.update(**self.args) + + +class ZoneDelete(task_manager.Task): + def main(self, client): + return client.designate_client.zones.delete(**self.args) diff --git a/shade/openstackcloud.py b/shade/openstackcloud.py index 920f5bbb7..f5753a35f 100644 --- a/shade/openstackcloud.py +++ b/shade/openstackcloud.py @@ -42,6 +42,7 @@ import swiftclient.client import swiftclient.service import swiftclient.exceptions as swift_exceptions import troveclient.client +import designateclient.client from shade.exc import * # noqa from shade import _log @@ -260,6 +261,7 @@ class OpenStackCloud(object): self._swift_client_lock = threading.Lock() self._swift_service_lock = threading.Lock() self._trove_client = None + self._designate_client = None self._raw_clients = {} @@ -848,6 +850,13 @@ class OpenStackCloud(object): 'network', neutronclient.neutron.client.Client) return self._neutron_client + @property + def designate_client(self): + if self._designate_client is None: + self._designate_client = self._get_client( + 'dns', designateclient.client.Client) + return self._designate_client + def create_stack( self, name, template_file=None, template_url=None, @@ -5093,3 +5102,113 @@ class OpenStackCloud(object): raise OpenStackCloudUnavailableFeature( "Unavailable feature: security groups" ) + + def list_zones(self): + """List all available zones. + + :returns: A list of zones dicts. + + """ + with _utils.shade_exceptions("Error fetching zones list"): + return self.manager.submitTask(_tasks.ZoneList()) + + def get_zone(self, name_or_id, filters=None): + """Get a zone by name or ID. + + :param name_or_id: Name or ID of the zone + :param dict filters: + A dictionary of meta data to use for further filtering + + :returns: A zone dict or None if no matching zone is + found. + + """ + return _utils._get_entity(self.search_zones, name_or_id, filters) + + def search_zones(self, name_or_id=None, filters=None): + zones = self.list_zones() + return _utils._filter_list(zones, name_or_id, filters) + + def create_zone(self, name, zone_type=None, email=None, description=None, + ttl=None, masters=None): + """Create a new zone. + + :param name: Name of the zone being created. + :param zone_type: Type of the zone (primary/secondary) + :param email: Email of the zone owner (only + applies if zone_type is primary) + :param description: Description of the zone + :param ttl: TTL (Time to live) value in seconds + :param masters: Master nameservers (only applies + if zone_type is secondary) + + :returns: a dict representing the created zone. + + :raises: OpenStackCloudException on operation error. + """ + + # We capitalize in case the user passes time in lowercase, as + # designate call expects PRIMARY/SECONDARY + if zone_type is not None: + zone_type = zone_type.upper() + if zone_type not in ('PRIMARY', 'SECONDARY'): + raise OpenStackCloudException( + "Invalid type %s, valid choices are PRIMARY or SECONDARY" % + zone_type) + + with _utils.shade_exceptions("Unable to create zone {name}".format( + name=name)): + return self.manager.submitTask(_tasks.ZoneCreate( + name=name, type_=zone_type, email=email, + description=description, ttl=ttl, masters=masters)) + + @_utils.valid_kwargs('email', 'description', 'ttl', 'masters') + def update_zone(self, name_or_id, **kwargs): + """Update a zone. + + :param name_or_id: Name or ID of the zone being updated. + :param email: Email of the zone owner (only + applies if zone_type is primary) + :param description: Description of the zone + :param ttl: TTL (Time to live) value in seconds + :param masters: Master nameservers (only applies + if zone_type is secondary) + + :returns: a dict representing the updated zone. + + :raises: OpenStackCloudException on operation error. + """ + zone = self.get_zone(name_or_id) + if not zone: + raise OpenStackCloudException( + "Zone %s not found." % name_or_id) + + with _utils.shade_exceptions( + "Error updating zone {0}".format(name_or_id)): + new_zone = self.manager.submitTask( + _tasks.ZoneUpdate( + zone=zone['id'], values=kwargs)) + + return new_zone + + def delete_zone(self, name_or_id): + """Delete a zone. + + :param name_or_id: Name or ID of the zone being deleted. + + :returns: True if delete succeeded, False otherwise. + + :raises: OpenStackCloudException on operation error. + """ + + zone = self.get_zone(name_or_id) + if zone is None: + self.log.debug("Zone %s not found for deleting" % name_or_id) + return False + + with _utils.shade_exceptions( + "Error deleting zone {0}".format(name_or_id)): + self.manager.submitTask( + _tasks.ZoneDelete(zone=zone['id'])) + + return True diff --git a/shade/tests/fakes.py b/shade/tests/fakes.py index ea196c624..6bf017697 100644 --- a/shade/tests/fakes.py +++ b/shade/tests/fakes.py @@ -248,3 +248,15 @@ class FakeStack(object): self.stack_name = name self.stack_description = description self.stack_status = status + + +class FakeZone(object): + def __init__(self, id, name, type_, email, description, + ttl, masters): + self.id = id + self.name = name + self.type_ = type_ + self.email = email + self.description = description + self.ttl = ttl + self.masters = masters diff --git a/shade/tests/functional/test_zone.py b/shade/tests/functional/test_zone.py new file mode 100644 index 000000000..62e2d7ce2 --- /dev/null +++ b/shade/tests/functional/test_zone.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- + +# 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. + +""" +test_zone +---------------------------------- + +Functional tests for `shade` zone methods. +""" + +from testtools import content + +from shade.tests.functional import base + + +class TestZone(base.BaseFunctionalTestCase): + + def setUp(self): + super(TestZone, self).setUp() + if not self.demo_cloud.has_service('dns'): + self.skipTest('dns service not supported by cloud') + + def test_zones(self): + '''Test DNS zones functionality''' + name = 'example.net.' + zone_type = 'primary' + email = 'test@example.net' + description = 'Test zone' + ttl = 3600 + masters = None + + self.addDetail('zone', content.text_content(name)) + self.addCleanup(self.cleanup, name) + + # Test we can create a zone and we get it returned + zone = self.demo_cloud.create_zone( + name=name, zone_type=zone_type, email=email, + description=description, ttl=ttl, + masters=masters) + self.assertEquals(zone['name'], name) + self.assertEquals(zone['type'], zone_type.upper()) + self.assertEquals(zone['email'], email) + self.assertEquals(zone['description'], description) + self.assertEquals(zone['ttl'], ttl) + self.assertEquals(zone['masters'], []) + + # Test that we can list zones + zones = self.demo_cloud.list_zones() + self.assertIsNotNone(zones) + + # Test we get the same zone with the get_zone method + zone_get = self.demo_cloud.get_zone(zone['id']) + self.assertEquals(zone_get['id'], zone['id']) + + # Test the get method also works by name + zone_get = self.demo_cloud.get_zone(name) + self.assertEquals(zone_get['name'], zone['name']) + + # Test we can update a field on the zone and only that field + # is updated + zone_update = self.demo_cloud.update_zone(zone['id'], ttl=7200) + self.assertEquals(zone_update['id'], zone['id']) + self.assertEquals(zone_update['name'], zone['name']) + self.assertEquals(zone_update['type'], zone['type']) + self.assertEquals(zone_update['email'], zone['email']) + self.assertEquals(zone_update['description'], zone['description']) + self.assertEquals(zone_update['ttl'], 7200) + self.assertEquals(zone_update['masters'], zone['masters']) + + # Test we can delete and get True returned + zone_delete = self.demo_cloud.delete_zone(zone['id']) + self.assertTrue(zone_delete) + + def cleanup(self, name): + self.demo_cloud.delete_zone(name) diff --git a/shade/tests/unit/test_zone.py b/shade/tests/unit/test_zone.py new file mode 100644 index 000000000..26315cc0d --- /dev/null +++ b/shade/tests/unit/test_zone.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- + +# 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 +import testtools + +import shade +from shade.tests.unit import base +from shade.tests import fakes + + +zone_obj = fakes.FakeZone( + id='1', + name='example.net.', + type_='PRIMARY', + email='test@example.net', + description='Example zone', + ttl=3600, + masters=None +) + + +class TestZone(base.TestCase): + + def setUp(self): + super(TestZone, self).setUp() + self.cloud = shade.openstack_cloud(validate=False) + + @mock.patch.object(shade.OpenStackCloud, 'designate_client') + def test_create_zone(self, mock_designate): + self.cloud.create_zone(name=zone_obj.name, zone_type=zone_obj.type_, + email=zone_obj.email, + description=zone_obj.description, + ttl=zone_obj.ttl, masters=zone_obj.masters) + mock_designate.zones.create.assert_called_once_with( + name=zone_obj.name, type_=zone_obj.type_.upper(), + email=zone_obj.email, description=zone_obj.description, + ttl=zone_obj.ttl, masters=zone_obj.masters + ) + + @mock.patch.object(shade.OpenStackCloud, 'designate_client') + def test_create_zone_exception(self, mock_designate): + mock_designate.zones.create.side_effect = Exception() + with testtools.ExpectedException( + shade.OpenStackCloudException, + "Unable to create zone example.net." + ): + self.cloud.create_zone('example.net.') + + @mock.patch.object(shade.OpenStackCloud, 'designate_client') + def test_update_zone(self, mock_designate): + new_ttl = 7200 + mock_designate.zones.list.return_value = [zone_obj] + self.cloud.update_zone('1', ttl=new_ttl) + mock_designate.zones.update.assert_called_once_with( + zone='1', values={'ttl': new_ttl} + ) + + @mock.patch.object(shade.OpenStackCloud, 'designate_client') + def test_delete_zone(self, mock_designate): + mock_designate.zones.list.return_value = [zone_obj] + self.cloud.delete_zone('1') + mock_designate.zones.delete.assert_called_once_with( + zone='1' + ) + + @mock.patch.object(shade.OpenStackCloud, 'designate_client') + def test_get_zone_by_id(self, mock_designate): + mock_designate.zones.list.return_value = [zone_obj] + zone = self.cloud.get_zone('1') + self.assertTrue(mock_designate.zones.list.called) + self.assertEqual(zone['id'], '1') + + @mock.patch.object(shade.OpenStackCloud, 'designate_client') + def test_get_zone_by_name(self, mock_designate): + mock_designate.zones.list.return_value = [zone_obj] + zone = self.cloud.get_zone('example.net.') + self.assertTrue(mock_designate.zones.list.called) + self.assertEqual(zone['name'], 'example.net.') + + @mock.patch.object(shade.OpenStackCloud, 'designate_client') + def test_get_zone_not_found_returns_false(self, mock_designate): + mock_designate.zones.list.return_value = [] + zone = self.cloud.get_zone('nonexistingzone.net.') + self.assertFalse(zone)