Asynchronous Zone Export

Do the needful to move Zone Exports to an asynchronous resource in the
v2 API, as discussed at the Austin 2015 summe mid-cycle

* Make designate-zone-manager an RPC service, with a read-only connection
  to the database
* Add a 'location' column to the zone_tasks table that stores a location
  (swift, URI) that is used to determine where the export will be made
  available to the user
* Add all the infrastucture to make zone export resources live (objects,
  central, storage methods)
* Add a quota on the size of allowed synchronous exports
* Tests, docs

THIS DOES NOT IMPLEMENT
* Zone exports to Swift
* Debateable: See the note in zone_manager/service.py about how the configuration
  and determination of future swift exports will work.

ApiImpact
Blueprint: async-export
Change-Id: I1c168b10358164c3ca5be986b4d615df71062851
This commit is contained in:
TimSimmons 2015-08-27 15:38:54 -05:00
parent 523395dec0
commit 50d1b1553e
30 changed files with 1197 additions and 69 deletions

View File

@ -47,6 +47,8 @@ cfg.CONF.register_opts([
cfg.StrOpt('mdns-topic', default='mdns', help='mDNS Topic'),
cfg.StrOpt('pool-manager-topic', default='pool_manager',
help='Pool Manager Topic'),
cfg.StrOpt('zone-manager-topic', default='zone_manager',
help='Zone Manager Topic'),
# Default TTL
cfg.IntOpt('default-ttl', default=3600),

View File

@ -33,6 +33,7 @@ from oslo_log import log as logging
from designate import exceptions
from designate.central import rpcapi as central_rpcapi
from designate.zone_manager import rpcapi as zone_manager_rpcapi
from designate.i18n import _
@ -54,6 +55,10 @@ class RestController(pecan.rest.RestController):
def central_api(self):
return central_rpcapi.CentralAPI.get_instance()
@property
def zone_manager_api(self):
return zone_manager_rpcapi.ZoneManagerAPI.get_instance()
def _apply_filter_params(self, params, accepted_filters, criterion):
invalid=[]
for k in params:

View File

@ -24,6 +24,10 @@ from designate.api.v2.controllers.zones.tasks import abandon
from designate.api.v2.controllers.zones.tasks.xfr import XfrController
from designate.api.v2.controllers.zones.tasks.imports \
import ZoneImportController
from designate.api.v2.controllers.zones.tasks.exports \
import ZoneExportsController
from designate.api.v2.controllers.zones.tasks.exports \
import ZoneExportCreateController
CONF = cfg.CONF
LOG = logging.getLogger(__name__)
@ -36,3 +40,5 @@ class TasksController(object):
abandon = abandon.AbandonController()
xfr = XfrController()
imports = ZoneImportController()
exports = ZoneExportsController()
export = ZoneExportCreateController()

View File

@ -0,0 +1,121 @@
# Copyright 2015 Rackspace Inc.
#
# Author: Tim Simmons <tim.simmons@rackspae.com>
#
# 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 pecan
from oslo_log import log as logging
from designate import exceptions
from designate import policy
from designate import utils
from designate.api.v2.controllers import rest
from designate.objects.adapters.api_v2.zone_export \
import ZoneExportAPIv2Adapter
LOG = logging.getLogger(__name__)
class ZoneExportController(rest.RestController):
@pecan.expose(template=None, content_type='text/dns')
@utils.validate_uuid('export_id')
def get_all(self, export_id):
context = pecan.request.environ['context']
policy.check('zone_export', context)
export = self.central_api.get_zone_export(context, export_id)
if export.location and export.location.startswith('designate://'):
return self.zone_manager_api.\
render_zone(context, export['domain_id'])
else:
msg = 'Zone can not be exported synchronously'
raise exceptions.BadRequest(msg)
class ZoneExportCreateController(rest.RestController):
@pecan.expose(template='json:', content_type='application/json')
@utils.validate_uuid('zone_id')
def post_all(self, zone_id):
"""Create Zone Export"""
request = pecan.request
response = pecan.response
context = request.environ['context']
# Create the zone_export
zone_export = self.central_api.create_zone_export(
context, zone_id)
response.status_int = 202
zone_export = ZoneExportAPIv2Adapter.render(
'API_v2', zone_export, request=request)
response.headers['Location'] = zone_export['links']['self']
return zone_export
class ZoneExportsController(rest.RestController):
SORT_KEYS = ['created_at', 'id', 'updated_at']
export = ZoneExportController()
@pecan.expose(template='json:', content_type='application/json')
@utils.validate_uuid('export_id')
def get_one(self, export_id):
"""Get Zone Exports"""
request = pecan.request
context = request.environ['context']
return ZoneExportAPIv2Adapter.render(
'API_v2',
self.central_api.get_zone_export(
context, export_id),
request=request)
@pecan.expose(template='json:', content_type='application/json')
def get_all(self, **params):
"""List Zone Exports"""
request = pecan.request
context = request.environ['context']
marker, limit, sort_key, sort_dir = utils.get_paging_params(
params, self.SORT_KEYS)
# Extract any filter params.
accepted_filters = ('status', 'message', 'zone_id', )
criterion = self._apply_filter_params(
params, accepted_filters, {})
return ZoneExportAPIv2Adapter.render(
'API_v2',
self.central_api.find_zone_exports(
context, criterion, marker, limit, sort_key, sort_dir),
request=request)
@pecan.expose(template='json:', content_type='application/json')
@utils.validate_uuid('zone_export_id')
def delete_one(self, zone_export_id):
"""Delete Zone Export"""
request = pecan.request
response = pecan.response
context = request.environ['context']
self.central_api.delete_zone_export(context, zone_export_id)
response.status_int = 204
return ''

View File

@ -50,14 +50,15 @@ class CentralAPI(object):
5.1 - Add xfr_domain
5.2 - Add Zone Import methods
5.3 - Add Zone Export method
5.4 - Add asynchronous Zone Export methods
"""
RPC_API_VERSION = '5.3'
RPC_API_VERSION = '5.4'
def __init__(self, topic=None):
topic = topic if topic else cfg.CONF.central_topic
target = messaging.Target(topic=topic, version=self.RPC_API_VERSION)
self.client = rpc.get_client(target, version_cap='5.3')
self.client = rpc.get_client(target, version_cap='5.4')
@classmethod
def get_instance(cls):
@ -535,3 +536,36 @@ class CentralAPI(object):
"delete_zone_import."))
return self.client.call(context, 'delete_zone_import',
zone_import_id=zone_import_id)
# Zone Export Methods
def create_zone_export(self, context, zone_id):
LOG.info(_LI("create_zone_export: Calling central's "
"create_zone_export."))
return self.client.call(context, 'create_zone_export',
zone_id=zone_id)
def find_zone_exports(self, context, criterion=None, marker=None,
limit=None, sort_key=None, sort_dir=None):
LOG.info(_LI("find_zone_exports: Calling central's "
"find_zone_exports."))
return self.client.call(context, 'find_zone_exports',
criterion=criterion, marker=marker,
limit=limit, sort_key=sort_key,
sort_dir=sort_dir)
def get_zone_export(self, context, zone_export_id):
LOG.info(_LI("get_zone_export: Calling central's get_zone_export."))
return self.client.call(context, 'get_zone_export',
zone_export_id=zone_export_id)
def update_zone_export(self, context, zone_export):
LOG.info(_LI("update_zone_export: Calling central's "
"update_zone_export."))
return self.client.call(context, 'update_zone_export',
zone_export=zone_export)
def delete_zone_export(self, context, zone_export_id):
LOG.info(_LI("delete_zone_export: Calling central's "
"delete_zone_export."))
return self.client.call(context, 'delete_zone_export',
zone_export_id=zone_export_id)

View File

@ -50,6 +50,7 @@ from designate import utils
from designate import storage
from designate.mdns import rpcapi as mdns_rpcapi
from designate.pool_manager import rpcapi as pool_manager_rpcapi
from designate.zone_manager import rpcapi as zone_manager_rpcapi
LOG = logging.getLogger(__name__)
@ -259,7 +260,7 @@ def notification(notification_type):
class Service(service.RPCService, service.Service):
RPC_API_VERSION = '5.3'
RPC_API_VERSION = '5.4'
target = messaging.Target(version=RPC_API_VERSION)
@ -307,6 +308,10 @@ class Service(service.RPCService, service.Service):
def pool_manager_api(self):
return pool_manager_rpcapi.PoolManagerAPI.get_instance()
@property
def zone_manager_api(self):
return zone_manager_rpcapi.ZoneManagerAPI.get_instance()
def _is_valid_domain_name(self, context, domain_name):
# Validate domain name length
if len(domain_name) > cfg.CONF['service:central'].max_domain_name_len:
@ -2611,3 +2616,68 @@ class Service(service.RPCService, service.Service):
zone_import = self.storage.delete_zone_import(context, zone_import_id)
return zone_import
# Zone Export Methods
@notification('dns.zone_export.create')
def create_zone_export(self, context, zone_id):
# Try getting the domain to ensure it exists
domain = self.storage.get_domain(context, zone_id)
target = {'tenant_id': context.tenant}
policy.check('create_zone_export', context, target)
values = {
'status': 'PENDING',
'message': None,
'domain_id': zone_id,
'tenant_id': context.tenant,
'task_type': 'EXPORT'
}
zone_export = objects.ZoneExport(**values)
created_zone_export = self.storage.create_zone_export(context,
zone_export)
self.zone_manager_api.start_zone_export(context, domain,
created_zone_export)
return created_zone_export
def find_zone_exports(self, context, criterion=None, marker=None,
limit=None, sort_key=None, sort_dir=None):
target = {'tenant_id': context.tenant}
policy.check('find_zone_exports', context, target)
criterion = {
'task_type': 'EXPORT'
}
return self.storage.find_zone_exports(context, criterion, marker,
limit, sort_key, sort_dir)
def get_zone_export(self, context, zone_export_id):
target = {'tenant_id': context.tenant}
policy.check('get_zone_export', context, target)
return self.storage.get_zone_export(context, zone_export_id)
@notification('dns.zone_export.update')
def update_zone_export(self, context, zone_export):
target = {
'tenant_id': zone_export.tenant_id,
}
policy.check('update_zone_export', context, target)
return self.storage.update_zone_export(context, zone_export)
@notification('dns.zone_export.delete')
@transaction
def delete_zone_export(self, context, zone_export_id):
target = {
'zone_export_id': zone_export_id,
'tenant_id': context.tenant
}
policy.check('delete_zone_export', context, target)
zone_export = self.storage.delete_zone_export(context, zone_export_id)
return zone_export

View File

@ -290,6 +290,10 @@ class DuplicateZoneImport(Duplicate):
error_type = 'duplicate_zone_import'
class DuplicateZoneExport(Duplicate):
error_type = 'duplicate_zone_export'
class MethodNotAllowed(Base):
expected = True
error_code = 405
@ -382,6 +386,10 @@ class ZoneImportNotFound(NotFound):
error_type = 'zone_import_not_found'
class ZoneExportNotFound(NotFound):
error_type = 'zone_export_not_found'
class LastServerDeleteNotAllowed(BadRequest):
error_type = 'last_server_delete_not_allowed'

View File

@ -44,6 +44,7 @@ from designate.objects.validation_error import ValidationErrorList # noqa
from designate.objects.zone_transfer_request import ZoneTransferRequest, ZoneTransferRequestList # noqa
from designate.objects.zone_transfer_accept import ZoneTransferAccept, ZoneTransferAcceptList # noqa
from designate.objects.zone_import import ZoneImport, ZoneImportList # noqa
from designate.objects.zone_export import ZoneExport, ZoneExportList # noqa
# Record Types

View File

@ -30,3 +30,4 @@ from designate.objects.adapters.api_v2.zone_transfer_accept import ZoneTransferA
from designate.objects.adapters.api_v2.zone_transfer_request import ZoneTransferRequestAPIv2Adapter, ZoneTransferRequestListAPIv2Adapter # noqa
from designate.objects.adapters.api_v2.validation_error import ValidationErrorAPIv2Adapter, ValidationErrorListAPIv2Adapter # noqa
from designate.objects.adapters.api_v2.zone_import import ZoneImportAPIv2Adapter, ZoneImportListAPIv2Adapter # noqa
from designate.objects.adapters.api_v2.zone_export import ZoneExportAPIv2Adapter, ZoneExportListAPIv2Adapter # noqa

View File

@ -0,0 +1,81 @@
# Copyright 2015 Rackspace Inc.
#
# Author: Tim Simmons <tim.simmons@rackspace.com>
#
# 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
from designate.objects.adapters.api_v2 import base
from designate import objects
LOG = logging.getLogger(__name__)
class ZoneExportAPIv2Adapter(base.APIv2Adapter):
ADAPTER_OBJECT = objects.ZoneExport
MODIFICATIONS = {
'fields': {
"id": {},
"status": {},
"message": {},
"location": {},
"zone_id": {
'rename': 'domain_id',
},
"project_id": {
'rename': 'tenant_id'
},
"created_at": {},
"updated_at": {},
"version": {},
},
'options': {
'links': True,
'resource_name': 'export',
'collection_name': 'exports',
}
}
@classmethod
def _get_path(cls, request):
return '/v2/zones/tasks/exports'
@classmethod
def _render_object(cls, object, *args, **kwargs):
obj = super(ZoneExportAPIv2Adapter, cls)._render_object(
object, *args, **kwargs)
if obj['location'] and obj['location'].startswith('designate://'):
# Get the base uri from the self link, which respects host headers
base_uri = obj['links']['self']. \
split(cls._get_path(kwargs['request']))[0]
obj['links']['export'] = \
'%s/%s' % \
(base_uri, obj['location'].split('://')[1])
return obj
class ZoneExportListAPIv2Adapter(base.APIv2Adapter):
ADAPTER_OBJECT = objects.ZoneExportList
MODIFICATIONS = {
'options': {
'links': True,
'resource_name': 'export',
'collection_name': 'exports',
}
}

View File

@ -0,0 +1,68 @@
# Copyright 2015 Rackspace Inc.
#
# Author: Tim Simmons <tim.simmons@rackspace.com>
#
# 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 designate.objects import base
class ZoneExport(base.DictObjectMixin, base.PersistentObjectMixin,
base.DesignateObject):
FIELDS = {
'status': {
'schema': {
"type": "string",
"enum": ["ACTIVE", "PENDING", "DELETED", "ERROR", "COMPLETE"],
},
'read_only': True
},
'task_type': {
'schema': {
"type": "string",
"enum": ["EXPORT"],
},
'read_only': True
},
'tenant_id': {
'schema': {
'type': 'string',
},
'read_only': True
},
'location': {
'schema': {
'type': ['string', 'null'],
'maxLength': 160
},
'read_only': True
},
'message': {
'schema': {
'type': ['string', 'null'],
'maxLength': 160
},
'read_only': True
},
'domain_id': {
'schema': {
"type": "string",
"format": "uuid"
},
'read_only': True
},
}
class ZoneExportList(base.ListObjectMixin, base.DesignateObject,
base.PagedListObjectMixin):
LIST_ITEM_TYPE = ZoneExport

View File

@ -32,6 +32,8 @@ cfg.CONF.register_opts([
help='Number of records allowed per domain'),
cfg.IntOpt('quota-recordset-records', default=20,
help='Number of records allowed per recordset'),
cfg.IntOpt('quota-api-export-size', default=1000,
help='Number of recordsets allowed in a zone export'),
])

View File

@ -55,6 +55,7 @@ class Quota(DriverPlugin):
'domain_recordsets': cfg.CONF.quota_domain_recordsets,
'domain_records': cfg.CONF.quota_domain_records,
'recordset_records': cfg.CONF.quota_recordset_records,
'api_export_size': cfg.CONF.quota_api_export_size,
}
def get_quota(self, context, tenant_id, resource):

View File

@ -691,6 +691,67 @@ class Storage(DriverPlugin):
:param zone_import_id: Delete a Zone Import via ID
"""
@abc.abstractmethod
def create_zone_export(self, context, zone_export):
"""
Create a Zone Export.
:param context: RPC Context.
:param zone_export: Zone Export object with the values to be created.
"""
@abc.abstractmethod
def get_zone_export(self, context, zone_export_id):
"""
Get a Zone Export via ID.
:param context: RPC Context.
:param zone_export_id: Zone Export ID to get.
"""
@abc.abstractmethod
def find_zone_exports(self, context, criterion=None, marker=None,
limit=None, sort_key=None, sort_dir=None):
"""
Find Zone Exports
:param context: RPC Context.
:param criterion: Criteria to filter by.
:param marker: Resource ID from which after the requested page will
start after
:param limit: Integer limit of objects of the page size after the
marker
:param sort_key: Key from which to sort after.
:param sort_dir: Direction to sort after using sort_key.
"""
@abc.abstractmethod
def find_zone_export(self, context, criterion):
"""
Find a single Zone Export.
:param context: RPC Context.
:param criterion: Criteria to filter by.
"""
@abc.abstractmethod
def update_zone_export(self, context, zone_export):
"""
Update a Zone Export
:param context: RPC Context.
:param zone_export: Zone Export to update.
"""
@abc.abstractmethod
def delete_zone_export(self, context, zone_export_id):
"""
Delete a Zone Export via ID.
:param context: RPC Context.
:param zone_export_id: Delete a Zone Export via ID
"""
def ping(self, context):
"""Ping the Storage connection"""
return {

View File

@ -1299,6 +1299,46 @@ class SQLAlchemyStorage(sqlalchemy_base.SQLAlchemy, storage_base.Storage):
return self._delete(context, tables.zone_tasks, zone_import,
exceptions.ZoneImportNotFound)
# Zone Export Methods
def _find_zone_exports(self, context, criterion, one=False, marker=None,
limit=None, sort_key=None, sort_dir=None):
if not criterion:
criterion = {}
criterion['task_type'] = 'EXPORT'
return self._find(
context, tables.zone_tasks, objects.ZoneExport,
objects.ZoneExportList, exceptions.ZoneExportNotFound, criterion,
one, marker, limit, sort_key, sort_dir)
def create_zone_export(self, context, zone_export):
return self._create(
tables.zone_tasks, zone_export, exceptions.DuplicateZoneExport)
def get_zone_export(self, context, zone_export_id):
return self._find_zone_exports(context, {'id': zone_export_id},
one=True)
def find_zone_exports(self, context, criterion=None, marker=None,
limit=None, sort_key=None, sort_dir=None):
return self._find_zone_exports(context, criterion, marker=marker,
limit=limit, sort_key=sort_key,
sort_dir=sort_dir)
def find_zone_export(self, context, criterion):
return self._find_zone_exports(context, criterion, one=True)
def update_zone_export(self, context, zone_export):
return self._update(
context, tables.zone_tasks, zone_export,
exceptions.DuplicateZoneExport, exceptions.ZoneExportNotFound)
def delete_zone_export(self, context, zone_export_id):
# Fetch the existing zone_export, we'll need to return it.
zone_export = self._find_zone_exports(context, {'id': zone_export_id},
one=True)
return self._delete(context, tables.zone_tasks, zone_export,
exceptions.ZoneExportNotFound)
# diagnostics
def ping(self, context):
start_time = time.time()

View File

@ -0,0 +1,44 @@
# Copyright 2015 Rackspace Inc.
#
# Author: Tim Simmons <tim.simmons@rackspace.com>
#
# 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 sqlalchemy import Enum, String
from sqlalchemy.schema import Column, MetaData, Table
meta = MetaData()
TASK_TYPES = ['IMPORT', 'EXPORT']
def upgrade(migrate_engine):
meta.bind = migrate_engine
dialect = migrate_engine.url.get_dialect().name
zone_tasks_table = Table('zone_tasks', meta, autoload=True)
dialect = migrate_engine.url.get_dialect().name
if dialect.startswith("postgresql"):
with migrate_engine.connect() as conn:
conn.execution_options(isolation_level="AUTOCOMMIT")
conn.execute(
"ALTER TYPE task_types ADD VALUE 'EXPORT' "
"AFTER 'IMPORT'")
conn.close()
zone_tasks_table.c.task_type.alter(type=Enum(name='task_type',
*TASK_TYPES))
location = Column('location', String(160), nullable=True)
location.create(zone_tasks_table)

View File

@ -39,7 +39,7 @@ ACTIONS = ['CREATE', 'DELETE', 'UPDATE', 'NONE']
ZONE_ATTRIBUTE_KEYS = ('master',)
ZONE_TYPES = ('PRIMARY', 'SECONDARY',)
ZONE_TASK_TYPES = ['IMPORT']
ZONE_TASK_TYPES = ['IMPORT', 'EXPORT']
metadata = MetaData()
@ -334,6 +334,7 @@ zone_tasks = Table('zone_tasks', metadata,
Column('status', Enum(name='resource_statuses', *TASK_STATUSES),
nullable=False, server_default='ACTIVE',
default='ACTIVE'),
Column('location', String(160), nullable=True),
mysql_engine='INNODB',
mysql_charset='utf8')

View File

@ -44,6 +44,7 @@ class QuotaTestCase(tests.TestCase):
self.assertIsNotNone(quotas)
self.assertEqual({
'api_export_size': cfg.CONF.quota_api_export_size,
'domains': cfg.CONF.quota_domains,
'domain_recordsets': cfg.CONF.quota_domain_recordsets,
'domain_records': cfg.CONF.quota_domain_records,

View File

@ -1753,3 +1753,112 @@ class IsSubdomainTestCase(CentralBasic):
r = self.service._is_subdomain(self.context, 'foo.a.b.example.com.',
'1')
self.assertEqual(r, 'example.com.')
class CentralZoneExportTests(CentralBasic):
def setUp(self):
super(CentralZoneExportTests, self).setUp()
def storage_find_tld(c, d):
if d['name'] not in ('org',):
raise exceptions.TldNotFound
self.service.storage.find_tld = storage_find_tld
def test_create_zone_export(self):
self.context = Mock()
self.context.tenant = 't'
self.service.storage.get_domain.return_value = RoObject(
name='example.com.',
id='123'
)
self.service.storage.create_zone_export = Mock(
return_value=RoObject(
domain_id='123',
task_type='EXPORT',
status='PENDING',
message=None,
tenant_id='t'
)
)
self.service.zone_manager_api.start_zone_export = Mock()
out = self.service.create_zone_export(
self.context,
'123'
)
self.assertEqual(out.domain_id, '123')
self.assertEqual(out.status, 'PENDING')
self.assertEqual(out.task_type, 'EXPORT')
self.assertEqual(out.message, None)
self.assertEqual(out.tenant_id, 't')
def test_get_zone_export(self):
self.context = Mock()
self.context.tenant = 't'
self.service.storage.get_zone_export.return_value = RoObject(
domain_id='123',
task_type='EXPORT',
status='PENDING',
message=None,
tenant_id='t'
)
out = self.service.get_zone_export(self.context, '1')
n, ctx, target = designate.central.service.policy.check.call_args[0]
# Check arguments to policy
self.assertEqual(target['tenant_id'], 't')
# Check output
self.assertEqual(out.domain_id, '123')
self.assertEqual(out.status, 'PENDING')
self.assertEqual(out.task_type, 'EXPORT')
self.assertEqual(out.message, None)
self.assertEqual(out.tenant_id, 't')
def test_find_zone_exports(self):
self.context = Mock()
self.context.tenant = 't'
self.service.storage.find_zone_exports = Mock()
self.service.find_zone_exports(self.context)
assert self.service.storage.find_zone_exports.called
pcheck, ctx, target = \
designate.central.service.policy.check.call_args[0]
self.assertEqual(pcheck, 'find_zone_exports')
def test_delete_zone_export(self):
self.context = Mock()
self.context.tenant = 't'
self.service.storage.delete_zone_export = Mock(
return_value=RoObject(
domain_id='123',
task_type='EXPORT',
status='PENDING',
message=None,
tenant_id='t'
)
)
out = self.service.delete_zone_export(self.context, '1')
assert self.service.storage.delete_zone_export.called
self.assertEqual(out.domain_id, '123')
self.assertEqual(out.status, 'PENDING')
self.assertEqual(out.task_type, 'EXPORT')
self.assertEqual(out.message, None)
self.assertEqual(out.tenant_id, 't')
assert designate.central.service.policy.check.called
pcheck, ctx, target = \
designate.central.service.policy.check.call_args[0]
self.assertEqual(pcheck, 'delete_zone_export')

View File

@ -27,7 +27,11 @@ OPTS = [
cfg.IntOpt('threads', default=1000,
help='Number of Zone Manager greenthreads to spawn'),
cfg.ListOpt('enabled_tasks', default=None,
help='Enabled tasks to run')
help='Enabled tasks to run'),
cfg.StrOpt('storage-driver', default='sqlalchemy',
help='The storage driver to use'),
cfg.BoolOpt('export-synchronous', default=True,
help='Whether to allow synchronous zone exports'),
]
CONF.register_opts(OPTS, group='service:zone_manager')

View File

@ -0,0 +1,72 @@
# Copyright 2015 Rackspace Inc.
#
# Author: Tim Simmons <tim.simmons@rackspace.com>
#
# 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_config import cfg
from oslo_log import log as logging
import oslo_messaging as messaging
from designate.i18n import _LI
from designate import rpc
LOG = logging.getLogger(__name__)
ZONE_MANAGER_API = None
class ZoneManagerAPI(object):
"""
Client side of the zone manager RPC API.
API version history:
1.0 - Initial version
"""
RPC_API_VERSION = '1.0'
def __init__(self, topic=None):
topic = topic if topic else cfg.CONF.zone_manager_topic
target = messaging.Target(topic=topic, version=self.RPC_API_VERSION)
self.client = rpc.get_client(target, version_cap='1.0')
@classmethod
def get_instance(cls):
"""
The rpc.get_client() which is called upon the API object initialization
will cause a assertion error if the designate.rpc.TRANSPORT isn't setup
by rpc.init() before.
This fixes that by creating the rpcapi when demanded.
"""
global ZONE_MANAGER_API
if not ZONE_MANAGER_API:
ZONE_MANAGER_API = cls()
return ZONE_MANAGER_API
# Zone Export
def start_zone_export(self, context, domain, export):
LOG.info(_LI("start_zone_export: "
"Calling zone_manager's start_zone_export."))
return self.client.cast(context, 'start_zone_export', domain=domain,
export=export)
def render_zone(self, context, zone_id):
LOG.info(_LI("render_zone: "
"Calling zone_manager's render_zone."))
return self.client.call(context, 'render_zone',
zone_id=zone_id)

View File

@ -15,10 +15,16 @@
# under the License.
from oslo_config import cfg
from oslo_log import log as logging
import oslo_messaging as messaging
from designate.i18n import _LI
from designate import coordination
from designate import exceptions
from designate import quota
from designate import service
from designate import storage
from designate import utils
from designate.central import rpcapi
from designate.zone_manager import tasks
@ -28,11 +34,29 @@ CONF = cfg.CONF
NS = 'designate.periodic_tasks'
class Service(coordination.CoordinationMixin, service.Service):
class Service(service.RPCService, coordination.CoordinationMixin,
service.Service):
RPC_API_VERSION = '1.0'
target = messaging.Target(version=RPC_API_VERSION)
def __init__(self, threads=None):
super(Service, self).__init__(threads=threads)
storage_driver = cfg.CONF['service:zone_manager'].storage_driver
self.storage = storage.get_storage(storage_driver)
# Get a quota manager instance
self.quota = quota.get_quota()
@property
def service_name(self):
return 'zone_manager'
@property
def central_api(self):
return rpcapi.CentralAPI.get_instance()
def start(self):
super(Service, self).start()
@ -59,3 +83,67 @@ class Service(coordination.CoordinationMixin, service.Service):
def _rebalance(self, my_partitions, members, event):
LOG.info(_LI("Received rebalance event %s") % event)
self.partition_range = my_partitions
# Begin RPC Implementation
# Zone Export
def start_zone_export(self, context, domain, export):
criterion = {'domain_id': domain.id}
count = self.storage.count_recordsets(context, criterion)
export = self._determine_export_method(context, export, count)
self.central_api.update_zone_export(context, export)
def render_zone(self, context, zone_id):
return self._export_zone(context, zone_id)
def _determine_export_method(self, context, export, size):
synchronous = CONF['service:zone_manager'].export_synchronous
# NOTE(timsim):
# The logic here with swift will work like this:
# cfg.CONF.export_swift_enabled:
# An export will land in their swift container, even if it's
# small, but the link that comes back will be the synchronous
# link (unless export_syncronous is False, in which case it
# will behave like the next option)
# cfg.CONF.export_swift_preffered:
# The link that the user gets back will always be the swift
# container, and status of the export resource will depend
# on the Swift process.
# If the export is too large for synchronous, or synchronous is not
# enabled and swift is not enabled, it will fall through to ERROR
# swift = False
if synchronous:
try:
self.quota.limit_check(
context, context.tenant, api_export_size=size)
except exceptions.OverQuota():
LOG.debug('Zone Export too large to perform synchronously')
export['status'] = 'ERROR'
export['message'] = 'Zone is too large to export'
return export
export['location'] = \
"designate://v2/zones/tasks/exports/%(eid)s/export" % \
{'eid': export['id']}
export['status'] = 'COMPLETE'
else:
LOG.debug('No method found to export zone')
export['status'] = 'ERROR'
export['message'] = 'No suitable method for export'
return export
def _export_zone(self, context, zone_id):
domain = self.central_api.get_domain(context, zone_id)
criterion = {'domain_id': zone_id}
recordsets = self.storage.find_recordsets_export(context, criterion)
return utils.render_template('export-zone.jinja2',
domain=domain,
recordsets=recordsets)

View File

@ -99,5 +99,4 @@ Admin API
:glob:
rest/admin/quotas
rest/admin/zones

View File

@ -1,59 +0,0 @@
Zones
=====
Overview
--------
The zones extension can be used to export zonesfiles from designate.
*Note*: Zones is an extension and needs to be enabled before it can be used.
If Designate returns a 404 error, ensure that the following line has been
added to the designate.conf file::
enabled_extensions_admin = zones
Once this line has been added, restart the designate-api service.
Export Zone
-----------
.. http:get:: /admin/zones/export/(uuid:id)
**Example request:**
.. sourcecode:: http
GET /admin/zones/export/a86dba58-0043-4cc6-a1bb-69d5e86f3ca3 HTTP/1.1
Host: 127.0.0.1:9001
Accept: text/dns
**Example response:**
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/dns
$ORIGIN example.com.
$TTL 42
example.com. IN SOA ns.designate.com. nsadmin.example.com. (
1394213803 ; serial
3600 ; refresh
600 ; retry
86400 ; expire
3600 ; minimum
)
example.com. IN NS ns.designate.com.
example.com. IN MX 10 mail.example.com.
ns.example.com. IN A 10.0.0.1
mail.example.com. IN A 10.0.0.2
:statuscode 200: Success
:statuscode 406: Not Acceptable
Notice how the SOA and NS records are replaced with the Designate server(s).

View File

@ -748,3 +748,232 @@ Delete Zone Import
HTTP/1.1 204 No Content
:statuscode 204: No Content
Export Zone
-----------
Create a Zone Export
^^^^^^^^^^^^^^^^^^^^
.. http:post:: /zones/(uuid:id)/tasks/export
To export a zone in BIND9 zonefile format, a zone export resource must be
created. This is accomplished by initializing an export task.
**Example request:**
.. sourcecode:: http
POST /v2/zones/074e805e-fe87-4cbb-b10b-21a06e215d41/tasks/export HTTP/1.1
Host: 127.0.0.1:9001
**Example response:**
.. sourcecode:: http
HTTP/1.1 202 Accepted
Content-Type: application/json
{
"status": "PENDING",
"zone_id": "074e805e-fe87-4cbb-b10b-21a06e215d41",
"links": {
"self": "http://127.0.0.1:9001/v2/zones/tasks/exports/8ec17fe1-d1f9-41b4-aa98-4eeb4c27b720"
},
"created_at": "2015-08-27T20:57:03.000000",
"updated_at": null,
"version": 1,
"location": null,
"message": null,
"project_id": "1",
"id": "8ec17fe1-d1f9-41b4-aa98-4eeb4c27b720"
}
:statuscode 202: Accepted
View a Zone Export Record
^^^^^^^^^^^^^^^^^^^^^^^^^
.. http:get:: /zones/tasks/exports/(uuid:id)
The status of a zone export can be viewed by querying the id
given when the request was created.
**Example request:**
.. sourcecode:: http
GET /v2/zones/tasks/exports/a86dba58-0043-4cc6-a1bb-69d5e86f3ca3 HTTP/1.1
Host: 127.0.0.1:9001
Accept: application/json
**Example response:**
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"status": "COMPLETE",
"zone_id": "6625198b-d67d-47dc-8d29-f90bd60f3ac4",
"links": {
"self": "http://127.0.0.1:9001/v2/zones/tasks/exports/8ec17fe1-d1f9-41b4-aa98-4eeb4c27b720",
"export": "http://127.0.0.1:9001/v2/zones/tasks/exports/8ec17fe1-d1f9-41b4-aa98-4eeb4c27b720/export"
},
"created_at": "2015-08-27T20:57:03.000000",
"updated_at": "2015-08-27T20:57:03.000000",
"version": 2,
"location": "designate://v2/zones/tasks/exports/8ec17fe1-d1f9-41b4-aa98-4eeb4c27b720/export",
"message": null,
"project_id": "noauth-project",
"id": "8ec17fe1-d1f9-41b4-aa98-4eeb4c27b720"
}
:statuscode 200: Success
:statuscode 401: Access Denied
:statuscode 404: Not Found
Notice the status has been updated and there is now an 'export' in the 'links' field that points
to a link where the export (zonefile) can be accessed.
View the Exported Zone
^^^^^^^^^^^^^^^^^^^^^^
The link that is generated in the export field in an export resource can be followed to
a Designate resource, or an external resource. If the link is to a Designate endpoint, the
zonefile can be retrieved directly through the API by following that link.
.. http:get:: /zones/tasks/exports/(uuid:id)
**Example request:**
.. sourcecode:: http
GET /zones/tasks/exports/8ec17fe1-d1f9-41b4-aa98-4eeb4c27b720/export HTTP/1.1
Host: 127.0.0.1:9001
Accept: text/dns
**Example response:**
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/dns
$ORIGIN example.com.
$TTL 42
example.com. IN SOA ns.designate.com. nsadmin.example.com. (
1394213803 ; serial
3600 ; refresh
600 ; retry
86400 ; expire
3600 ; minimum
)
example.com. IN NS ns.designate.com.
example.com. IN MX 10 mail.example.com.
ns.example.com. IN A 10.0.0.1
mail.example.com. IN A 10.0.0.2
:statuscode 200: Success
:statuscode 401: Access Denied
:statuscode 404: Not Found
Notice how the SOA and NS records are replaced with the Designate server(s).
List Zone Exports
^^^^^^^^^^^^^^^^^
.. http:get:: /zones/tasks/exports/
List all of the zone exports created by this project.
**Example request:**
.. sourcecode:: http
GET /v2/zones/tasks/exports/ HTTP/1.1
Host: 127.0.0.1:9001
Accept: application/json
**Example response:**
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"exports": [
{
"status": "COMPLETE",
"zone_id": "30ea7692-7f9e-4195-889e-0ba11620b491",
"links": {
"self": "http://127.0.0.1:9001/v2/zones/tasks/exports/d2f36aa6-2da4-4b22-a2a9-9cdf19a2f248",
"export": "http://127.0.0.1:9001/v2/zones/30ea7692-7f9e-4195-889e-0ba11620b491/tasks/exports/d2f36aa6-2da4-4b22-a2a9-9cdf19a2f248/export"
},
"created_at": "2015-08-24T19:46:50.000000",
"updated_at": "2015-08-24T19:46:50.000000",
"version": 2,
"location": "designate://v2/zones/30ea7692-7f9e-4195-889e-0ba11620b491/tasks/exports/d2f36aa6-2da4-4b22-a2a9-9cdf19a2f248/export",
"message": null,
"project_id": "noauth-project",
"id": "d2f36aa6-2da4-4b22-a2a9-9cdf19a2f248"
},
{
"status": "COMPLETE",
"zone_id": "0503f9fd-3938-47a4-bbf3-df99b088abfc",
"links": {
"self": "http://127.0.0.1:9001/v2/zones/tasks/exports/3d7d07a5-2ce3-458e-b3dd-6a29906234d8",
"export": "http://127.0.0.1:9001/v2/zones/tasks/exports/3d7d07a5-2ce3-458e-b3dd-6a29906234d8/export"
},
"created_at": "2015-08-25T15:16:10.000000",
"updated_at": "2015-08-25T15:16:10.000000",
"version": 2,
"location": "designate://v2/zones/tasks/exports/3d7d07a5-2ce3-458e-b3dd-6a29906234d8/export",
"message": null,
"project_id": "noauth-project",
"id": "3d7d07a5-2ce3-458e-b3dd-6a29906234d8"
},
],
"links": {
"self": "http://127.0.0.1:9001/v2/zones/tasks/exports"
}
}
:statuscode 200: Success
:statuscode 401: Access Denied
:statuscode 404: Not Found
Delete Zone Export
^^^^^^^^^^^^^^^^^^
.. http:delete:: /zones/tasks/exports/(uuid:id)
Deletes a zone export with the specified ID. This does not affect the zone
that was exported, it simply removes the record of the export. If the link
to view the export was pointing to a Designate API endpoint, the endpoint
will no longer be available.
**Example Request:**
.. sourcecode:: http
DELETE /v2/zones/tasks/exports/a86dba58-0043-4cc6-a1bb-69d5e86f3ca3 HTTP/1.1
Host: 127.0.0.1:9001
Accept: application/json
Content-Type: application/json
**Example Response:**
.. sourcecode:: http
HTTP/1.1 204 No Content
:statuscode 204: No Content

View File

@ -216,6 +216,9 @@ debug = False
# Can be one or more of: periodic_exists
#enabled_tasks = None
# Whether to allow synchronous zone exports
#export_synchronous = True
#-----------------------
# Pool Manager Service
#-----------------------

View File

@ -108,11 +108,16 @@
"update_zone_transfer_accept": "rule:admin",
"delete_zone_transfer_accept": "rule:admin",
"zone_export": "rule:admin_or_owner",
"create_zone_import": "rule:admin_or_owner",
"find_zone_imports": "rule:admin_or_owner",
"get_zone_import": "rule:admin_or_owner",
"update_zone_import": "rule:admin_or_owner",
"delete_zone_import": "rule:admin_or_owner"
"delete_zone_import": "rule:admin_or_owner",
"zone_export": "rule:admin_or_owner",
"create_zone_export": "rule:admin_or_owner",
"find_zone_exports": "rule:admin_or_owner",
"get_zone_export": "rule:admin_or_owner",
"update_zone_export": "rule:admin_or_owner",
"delete_zone_export": "rule:admin_or_owner"
}

View File

@ -0,0 +1,71 @@
"""
Copyright 2015 Rackspace
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 functionaltests.api.v2.models.zone_export_model import ZoneExportModel
from functionaltests.api.v2.models.zone_export_model import ZoneExportListModel
from functionaltests.common.client import ClientMixin
from functionaltests.common import utils
class ZoneExportClient(ClientMixin):
@classmethod
def zone_exports_uri(cls, filters=None):
url = "/v2/zones/tasks/exports"
if filters:
url = cls.add_filters(url, filters)
return url
@classmethod
def zone_export_uri(cls, id):
return "{0}/{1}".format(cls.zone_exports_uri(), id)
def list_zone_exports(self, filters=None, **kwargs):
resp, body = self.client.get(
self.zone_exports_uri(filters), **kwargs)
return self.deserialize(resp, body, ZoneExportListModel)
def get_zone_export(self, id, **kwargs):
resp, body = self.client.get(self.zone_export_uri(id))
return self.deserialize(resp, body, ZoneExportModel)
def get_exported_zone(self, id, **kwargs):
uri = "/v2/zones/tasks/exports/{0}".format(id)
resp, body = self.client.get(uri)
return resp, body
def post_zone_export(self, zone_id, **kwargs):
uri = "/v2/zones/{0}/tasks/export".format(zone_id)
resp, body = self.client.post(uri, body='', **kwargs)
return self.deserialize(resp, body, ZoneExportModel)
def delete_zone_export(self, id, **kwargs):
resp, body = self.client.delete(self.zone_export_uri(id), **kwargs)
return resp, body
def wait_for_zone_export(self, zone_export_id):
utils.wait_for_condition(
lambda: self.is_zone_export_active(zone_export_id))
def is_zone_export_active(self, zone_export_id):
resp, model = self.get_zone_export(zone_export_id)
# don't have assertEqual but still want to fail fast
assert resp.status == 200
if model.status == 'COMPLETE':
return True
elif model.status == 'ERROR':
raise Exception("Saw ERROR status")
return False

View File

@ -0,0 +1,27 @@
"""
Copyright 2015 Rackspace
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 functionaltests.common.models import BaseModel
from functionaltests.common.models import CollectionModel
class ZoneExportModel(BaseModel):
pass
class ZoneExportListModel(CollectionModel):
COLLECTION_NAME = 'exports'
MODEL_TYPE = ZoneExportModel

View File

@ -22,6 +22,7 @@ from functionaltests.common import datagen
from functionaltests.api.v2.base import DesignateV2Test
from functionaltests.api.v2.clients.zone_client import ZoneClient
from functionaltests.api.v2.clients.zone_import_client import ZoneImportClient
from functionaltests.api.v2.clients.zone_export_client import ZoneExportClient
class ZoneTest(DesignateV2Test):
@ -140,3 +141,35 @@ class ZoneImportTest(DesignateV2Test):
import_client.delete_zone_import(import_id)
self.assertRaises(NotFound,
lambda: import_client.get_zone_import(model.id))
class ZoneExportTest(DesignateV2Test):
def setUp(self):
super(ZoneExportTest, self).setUp()
def test_import_domain(self):
user = 'default'
resp, zone = ZoneClient.as_user(user).post_zone(
datagen.random_zone_data())
ZoneClient.as_user(user).wait_for_zone(zone.id)
export_client = ZoneExportClient.as_user(user)
resp, model = export_client.post_zone_export(zone.id)
export_id = model.id
self.assertEqual(resp.status, 202)
self.assertEqual(model.status, 'PENDING')
export_client.wait_for_zone_export(export_id)
resp, model = export_client.get_zone_export(export_id)
self.assertEqual(resp.status, 200)
self.assertEqual(model.status, 'COMPLETE')
resp, body = export_client.get_exported_zone(export_id)
self.assertEqual(resp.status, 200)
export_client.delete_zone_export(export_id)
self.assertRaises(NotFound,
lambda: export_client.get_zone_export(model.id))