From 8733f8f85bf0b8c3cedc35d9a41a511a3cb8dd78 Mon Sep 17 00:00:00 2001 From: kpdev Date: Wed, 16 Jun 2021 13:43:03 +0200 Subject: [PATCH] Add new API for zone move The new API would be v2/zones//tasks/move Only POST would be allowed on this API. This move zone from existing pool and add it in new pool. After zone pool_id field will be updated in DB, clone-zone will be created on target pool backend servers. The zone transfer(AXFR/IXFR) will happen and the zone on target pool gets synced with the Designate DB. This command serve as replacement to "zone export + zone delete + zone import" procedure. Added following things in pool move operation: - Add/Update NS servers of new pool in the zone Implements: blueprint zone-move Change-Id: I5307de429114b20efd9785c3c0cdb33977418423 --- api-ref/source/dns-api-v2-zone-tasks.inc | 55 ++++++++++++- api-ref/source/parameters.yaml | 7 ++ .../samples/zones/poolmove-zone-request.json | 3 + .../v2/controllers/zones/tasks/__init__.py | 3 + .../v2/controllers/zones/tasks/pool_move.py | 64 +++++++++++++++ designate/central/rpcapi.py | 10 ++- designate/central/service.py | 77 ++++++++++++++++++- designate/common/policies/zone.py | 19 +++++ designate/objects/adapters/api_v2/zone.py | 4 +- .../tests/test_api/test_v2/test_zones.py | 50 ++++++++++++ designate/tests/test_central/test_service.py | 60 +++++++++++++++ .../zone-pool-move-7bb8e1f0839c3c0d.yaml | 10 +++ 12 files changed, 357 insertions(+), 5 deletions(-) create mode 100644 api-ref/source/samples/zones/poolmove-zone-request.json create mode 100644 designate/api/v2/controllers/zones/tasks/pool_move.py create mode 100644 releasenotes/notes/zone-pool-move-7bb8e1f0839c3c0d.yaml diff --git a/api-ref/source/dns-api-v2-zone-tasks.inc b/api-ref/source/dns-api-v2-zone-tasks.inc index 62a46a33d..a34160e7a 100644 --- a/api-ref/source/dns-api-v2-zone-tasks.inc +++ b/api-ref/source/dns-api-v2-zone-tasks.inc @@ -46,7 +46,6 @@ Request - zone_id: path_zone_id - Response Parameters ------------------- @@ -102,3 +101,57 @@ Response Parameters .. rest_parameters:: parameters.yaml - x-openstack-request-id: x-openstack-request-id + + + +Pool Move Zone +============== + +.. rest_method:: POST /v2/zones/{zone_id}/tasks/pool_move + +Move a zone to another pool. + +This moves a zone from the existing designate pool to specified target pool. If +pool is not specified by admin, designate will determine suitable pool by +itself and move zone to that pool. + +.. rest_status_code:: success status.yaml + + - 202 + + +.. rest_status_code:: error status.yaml + + - 400 + - 401 + - 403 + - 404 + - 405 + - 500 + - 503 + + +Request +------- + +.. rest_parameters:: parameters.yaml + + - x-auth-token: x-auth-token + - x-auth-all-projects: x-auth-all-projects + - x-auth-sudo-project-id: x-auth-sudo-project-id + - zone_id: path_zone_id + - pool_id: zone_pool_target_id + +Request Example +--------------- + +.. literalinclude:: samples/zones/poolmove-zone-request.json + :language: javascript + + +Response Parameters +------------------- + +.. rest_parameters:: parameters.yaml + + - x-openstack-request-id: x-openstack-request-id diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml index ba7aa4e92..8f63c9eb8 100644 --- a/api-ref/source/parameters.yaml +++ b/api-ref/source/parameters.yaml @@ -953,6 +953,13 @@ zone_pool_id: required: true type: uuid +zone_pool_target_id: + description: | + The target pool ID to move the zone into + in: body + required: false + type: uuid + zone_serial: description: | current serial number for the zone diff --git a/api-ref/source/samples/zones/poolmove-zone-request.json b/api-ref/source/samples/zones/poolmove-zone-request.json new file mode 100644 index 000000000..0a9a4b080 --- /dev/null +++ b/api-ref/source/samples/zones/poolmove-zone-request.json @@ -0,0 +1,3 @@ +{ + "pool_id": "8e6f1c59-15e7-4a14-8640-8d5e07f95b10" +} diff --git a/designate/api/v2/controllers/zones/tasks/__init__.py b/designate/api/v2/controllers/zones/tasks/__init__.py index 00e1e8b12..e8b540fab 100644 --- a/designate/api/v2/controllers/zones/tasks/__init__.py +++ b/designate/api/v2/controllers/zones/tasks/__init__.py @@ -22,6 +22,8 @@ from designate.api.v2.controllers.zones.tasks.exports import ( ZoneExportsController) from designate.api.v2.controllers.zones.tasks.imports import ( ZoneImportController) +from designate.api.v2.controllers.zones.tasks.pool_move import ( + PoolMoveController) from designate.api.v2.controllers.zones.tasks.transfer_accepts import ( TransferAcceptsController as TRA) from designate.api.v2.controllers.zones.tasks.transfer_requests import ( @@ -37,6 +39,7 @@ class TasksController: transfer_requests = TRC() abandon = abandon.AbandonController() xfr = XfrController() + pool_move = PoolMoveController() imports = ZoneImportController() exports = ZoneExportsController() export = ZoneExportCreateController() diff --git a/designate/api/v2/controllers/zones/tasks/pool_move.py b/designate/api/v2/controllers/zones/tasks/pool_move.py new file mode 100644 index 000000000..f32073dfa --- /dev/null +++ b/designate/api/v2/controllers/zones/tasks/pool_move.py @@ -0,0 +1,64 @@ +# Copyright 2022 Cloudification GmbH +# +# Author: Kiran P +# +# 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 oslo_log import log as logging +import pecan + +from designate.api.v2.controllers import rest +from designate import exceptions +from designate.objects.adapters import DesignateAdapter +from designate import utils + +LOG = logging.getLogger(__name__) + + +class PoolMoveController(rest.RestController): + + @pecan.expose(template='json:', content_type='application/json') + @pecan.expose(template='json:', content_type='application/json-patch+json') + @utils.validate_uuid('zone_id') + def post_all(self, zone_id): + """Move a zone""" + request = pecan.request + response = pecan.response + body = request.body_dict + context = request.environ['context'] + + zone = self.central_api.get_zone(context, zone_id) + + if zone.action == "DELETE": + raise exceptions.BadRequest('Can not move a deleting zone') + + target_pool_id = None + if 'pool_id' in body: + if zone.pool_id == body['pool_id']: + raise exceptions.BadRequest( + 'Target pool must be different for zone pool move') + target_pool_id = body['pool_id'] + + # Update the zone object with the new values + zone = DesignateAdapter.parse('API_v2', body, zone) + zone.validate() + + LOG.info("Triggered pool move for %(zone)s", {'zone': zone}) + zone = self.central_api.pool_move_zone( + context, zone_id, target_pool_id) + if zone.status == 'PENDING': + response.status_int = 202 + else: + response.status_int = 500 + + return '' diff --git a/designate/central/rpcapi.py b/designate/central/rpcapi.py index 35aa3f134..60782b6ed 100644 --- a/designate/central/rpcapi.py +++ b/designate/central/rpcapi.py @@ -72,8 +72,9 @@ class CentralAPI: 6.7 - Add increment_zone_serial 6.8 - Add managed recordset methods 6.9 - Removed unused methods + 6.10 - Add Zone Pool Move method """ - RPC_API_VERSION = '6.9' + RPC_API_VERSION = '6.10' # This allows us to mark some methods as not logged. # This can be for a few reasons - some methods my not actually call over @@ -86,7 +87,7 @@ class CentralAPI: target = messaging.Target(topic=self.topic, version=self.RPC_API_VERSION) - self.client = rpc.get_client(target, version_cap='6.9') + self.client = rpc.get_client(target, version_cap='6.10') @classmethod def get_instance(cls): @@ -175,6 +176,11 @@ class CentralAPI: return self.client.call(context, 'purge_zones', criterion=criterion, limit=limit) + def pool_move_zone(self, context, zone_id, target_pool_id): + return self.client.call(context, 'pool_move_zone', + zone_id=zone_id, + target_pool_id=target_pool_id) + # Shared Zone methods def share_zone(self, context, zone_id, shared_zone): return self.client.call(context, 'share_zone', zone_id=zone_id, diff --git a/designate/central/service.py b/designate/central/service.py index 2db44c8b5..e58f9a10f 100644 --- a/designate/central/service.py +++ b/designate/central/service.py @@ -54,7 +54,7 @@ LOG = logging.getLogger(__name__) class Service(service.RPCService): - RPC_API_VERSION = '6.9' + RPC_API_VERSION = '6.10' target = messaging.Target(version=RPC_API_VERSION) @@ -1329,6 +1329,81 @@ class Service(service.RPCService): "Could not find %s" % zone.obj_name()) return zone_shared + @rpc.expected_exceptions() + @notification.notify_type('dns.domain.update') + @notification.notify_type('dns.zone.update') + def pool_move_zone(self, context, zone_id, target_pool_id=None): + """Move zone. Perform checks and then create zone in destination pool + + :returns: moved zone + """ + if policy.enforce_new_defaults(): + target = { + 'zone_id': zone_id, + constants.RBAC_PROJECT_ID: context.project_id, + } + else: + target = { + 'zone_id': zone_id, + 'tenant_id': context.project_id, + } + + policy.check('pool_move_zone', context, target) + + # Get the destination pool + zone = self.storage.get_zone(context, zone_id) + orig_pool_id = zone.pool_id + + if target_pool_id is None: + target_pool_id = self.scheduler.schedule_zone(context, zone) + if target_pool_id == orig_pool_id: + raise exceptions.BadRequest('No valid pool selected') + # Update the orignal zone with new pool_id + zone.pool_id = target_pool_id + + # Need elevated context to get the pool + elevated_context = context.elevated(all_tenants=True) + try: + self.storage.get_pool(elevated_context, target_pool_id) + except exceptions.PoolNotFound: + raise exceptions.BadRequest('Target pool does not exist') + + target_pool_ns_records = self._get_pool_ns_records(context, + target_pool_id) + if len(target_pool_ns_records) == 0: + LOG.critical('No nameservers configured. Please create at least ' + 'one nameserver on target pool') + raise exceptions.NoServersConfigured() + + orig_pool_ns_records = self._get_pool_ns_records(context, + orig_pool_id) + + target_ns = {n.hostname for n in target_pool_ns_records} + orig_ns = {n.hostname for n in orig_pool_ns_records} + create_ns = target_ns.difference(orig_ns) + delete_ns = orig_ns.difference(target_ns) + + # Update target NS servers for the zone + for ns_record in create_ns: + self._add_ns(elevated_context, zone, ns_record) + + # Then handle the ns_records to delete + for ns_record in delete_ns: + self._delete_ns(elevated_context, zone, ns_record) + + zone = self._update_zone_in_storage( + context, zone, increment_serial=False) + + LOG.info("Moving zone '%(zone)s' to pool '%(pool)s'", + {'zone': zone.name, 'pool': target_pool_id}) + zone.pool_id = target_pool_id + zone.refresh = self._generate_soa_refresh_interval() + zone.action = 'CREATE' + zone.status = 'PENDING' + self.worker_api.create_zone(context, zone) + + return zone + # RecordSet Methods @rpc.expected_exceptions() @notification.notify_type('dns.recordset.create') diff --git a/designate/common/policies/zone.py b/designate/common/policies/zone.py index 669f2c112..a58a92958 100644 --- a/designate/common/policies/zone.py +++ b/designate/common/policies/zone.py @@ -101,6 +101,12 @@ deprecated_purge_zones = policy.DeprecatedRule( deprecated_reason=DEPRECATED_REASON, deprecated_since=versionutils.deprecated.WALLABY ) +deprecated_pool_move_zone = policy.DeprecatedRule( + name="pool_move_zone", + check_str=base.RULE_ADMIN, + deprecated_reason=DEPRECATED_REASON, + deprecated_since=versionutils.deprecated.WALLABY +) rules = [ @@ -238,6 +244,19 @@ rules = [ scope_types=[constants.PROJECT], deprecated_rule=deprecated_purge_zones ), + policy.DocumentedRuleDefault( + name="pool_move_zone", + check_str=base.SYSTEM_ADMIN, + scope_types=[constants.PROJECT], + description="Pool Move Zone", + operations=[ + { + 'path': '/v2/zones/{zone_id}/tasks/pool_move', + 'method': 'POST' + } + ], + deprecated_rule=deprecated_pool_move_zone, + ) ] diff --git a/designate/objects/adapters/api_v2/zone.py b/designate/objects/adapters/api_v2/zone.py index c1220a318..45af29e0a 100644 --- a/designate/objects/adapters/api_v2/zone.py +++ b/designate/objects/adapters/api_v2/zone.py @@ -20,7 +20,9 @@ class ZoneAPIv2Adapter(base.APIv2Adapter): MODIFICATIONS = { 'fields': { "id": {}, - "pool_id": {}, + "pool_id": { + 'read_only': False + }, "project_id": { 'rename': 'tenant_id' }, diff --git a/designate/tests/test_api/test_v2/test_zones.py b/designate/tests/test_api/test_v2/test_zones.py index 1d35bcabe..f5b850e12 100644 --- a/designate/tests/test_api/test_v2/test_zones.py +++ b/designate/tests/test_api/test_v2/test_zones.py @@ -517,6 +517,56 @@ class ApiV2ZonesTest(ApiV2TestCase): self._assert_exception('not_found', 404, self.client.get, url, headers={'X-Test-Role': 'member'}) + def test_post_pool_zone_move_invalid_pool_id(self): + zone = self.create_zone() + body = {'pool_id': zone.pool_id} + self._assert_exception('bad_request', 400, self.client.post_json, + '/zones/%s/tasks/pool_move' % zone['id'], + body, headers={'X-Test-Role': 'admin'}) + + def test_post_pool_zone_move_invalid_action(self): + # Create a zone + zone = self.create_zone() + body = {'pool_id': '12345'} + zone.action = 'DELETE' + with mock.patch.object(central_service.Service, 'get_zone', + return_value=zone): + self._assert_exception('bad_request', 400, + self.client.post_json, + '/zones/%s/tasks/pool_move' % zone['id'], + body, headers={'X-Test-Role': 'admin'}) + + def test_post_pool_zone_move_non_admin_user(self): + # Create a zone + zone = self.create_zone() + body = {'pool_id': '12345'} + self._assert_exception('forbidden', 403, self.client.post_json, + '/zones/%s/tasks/pool_move' % zone['id'], body) + + def test_post_pool_zone_move_admin_user_status_500(self): + # Create a zone + zone = self.create_zone() + body = {'pool_id': '12345'} + response = self.client.post_json( + '/zones/%s/tasks/pool_move' % zone['id'], + body, status=500, headers={'X-Test-Role': 'admin'}) + + # Check the headers are what we expect + self.assertEqual(500, response.status_int) + self.assertEqual('application/json', response.content_type) + + def test_post_pool_zone_move_admin_user_status_202(self): + # Create a zone + zone = self.create_zone() + body = {'pool_id': '12345'} + zone.status = 'PENDING' + with mock.patch.object(central_service.Service, 'pool_move_zone', + return_value=zone): + response = self.client.post_json( + '/zones/%s/tasks/pool_move' % zone['id'], body, + headers={'X-Test-Role': 'admin'}) + self.assertEqual(202, response.status_int) + def test_get_zone_tasks(self): # This is an invalid endpoint - should return 404 zone = self.create_zone() diff --git a/designate/tests/test_central/test_service.py b/designate/tests/test_central/test_service.py index b3de7429a..24c28d9da 100644 --- a/designate/tests/test_central/test_service.py +++ b/designate/tests/test_central/test_service.py @@ -4240,6 +4240,66 @@ class CentralServiceTest(designate.tests.TestCase): self.assertEqual(shared_zone.project_id, retrived_shared_zone.project_id) + def test_pool_move_zone(self): + pool = self.create_pool(fixture=0) + zone = self.create_zone(context=self.admin_context, pool_id=pool.id) + + # create second pool + second_pool = self.create_pool(fixture=1) + new_ns_record = objects.PoolNsRecord(hostname='ns-new.example.org.') + second_pool.ns_records.append(new_ns_record) + + moved_zone = self.central_service.pool_move_zone( + self.admin_context, + zone.id, second_pool['id']) + self.assertEqual(zone.id, moved_zone.id) + self.assertEqual(moved_zone.pool_id, second_pool['id']) + + def test_pool_move_zone_without_target_pool(self): + pool = self.create_pool(fixture=0) + zone = self.create_zone(context=self.admin_context, pool_id=pool.id) + + # create second pool + second_pool = self.create_pool(fixture=1) + new_ns_record = objects.PoolNsRecord(hostname='ns-new.example.org.') + second_pool.ns_records.append(new_ns_record) + + zone.pool_id = None + with mock.patch.object(self.central_service.scheduler, 'schedule_zone', + return_value=second_pool['id']): + moved_zone = self.central_service.pool_move_zone( + self.admin_context, + zone.id) + self.assertEqual(zone.id, moved_zone.id) + self.assertEqual(moved_zone.pool_id, second_pool['id']) + + def test_pool_move_zone_exception_no_ns_records(self): + pool = self.create_pool(fixture=0) + zone = self.create_zone(context=self.admin_context, pool_id=pool.id) + + # create second pool + second_pool = self.create_pool(fixture=1) + + zone.pool_id = second_pool['id'] + with mock.patch.object(self.central_service, '_get_pool_ns_records', + return_value=[]): + self.assertRaises(exceptions.NoServersConfigured, + self.central_service.pool_move_zone, + self.admin_context, + zone.id, second_pool['id']) + + def test_pool_move_zone_exception_invalid_pool_id(self): + pool = self.create_pool(fixture=0) + zone = self.create_zone(context=self.admin_context, pool_id=pool.id) + + # Use fake pool ID + pool_id = '521935cf-d5be-44a2-9f64-fb5a316a61d2' + exc = self.assertRaises(rpc_dispatcher.ExpectedException, + self.central_service.pool_move_zone, + self.admin_context, + zone.id, target_pool_id=pool_id) + self.assertEqual(exceptions.BadRequest, exc.exc_info[0]) + def test_create_managed_records(self): zone = self.create_zone() diff --git a/releasenotes/notes/zone-pool-move-7bb8e1f0839c3c0d.yaml b/releasenotes/notes/zone-pool-move-7bb8e1f0839c3c0d.yaml new file mode 100644 index 000000000..1fe8e5e45 --- /dev/null +++ b/releasenotes/notes/zone-pool-move-7bb8e1f0839c3c0d.yaml @@ -0,0 +1,10 @@ +--- +features: + - | + Added zone pool move command which allows admin user to move zone from + pool A to specified pool B. This command overcome the issues observed in + zone export-import thereby reducing hours of time of large zone imports + (e.g. 20-30k records). Please note, if you have moved a zone to a + different pool, the pool must be configured with a proper tsig key for + mini-DNS query operations. Without this, you cannot have overlapping zones + in different pools.