Add new API for zone move

The new API would be v2/zones/<zone_id>/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
This commit is contained in:
kpdev 2021-06-16 13:43:03 +02:00 committed by Michael Johnson
parent f5a034272d
commit 8733f8f85b
12 changed files with 357 additions and 5 deletions

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,3 @@
{
"pool_id": "8e6f1c59-15e7-4a14-8640-8d5e07f95b10"
}

View File

@ -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()

View File

@ -0,0 +1,64 @@
# Copyright 2022 Cloudification GmbH
#
# Author: Kiran P <contact@cloudification.io>
#
# 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 ''

View File

@ -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,

View File

@ -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')

View File

@ -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,
)
]

View File

@ -20,7 +20,9 @@ class ZoneAPIv2Adapter(base.APIv2Adapter):
MODIFICATIONS = {
'fields': {
"id": {},
"pool_id": {},
"pool_id": {
'read_only': False
},
"project_id": {
'rename': 'tenant_id'
},

View File

@ -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()

View File

@ -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()

View File

@ -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.