Asynchronous Zone Import

* Creates /v2/zones/tasks/imports, which allows users to view imports as resources
* Creates new database table zone_tasks for asynchronous tasks related to zones, along
with the associated objects/adapters
* Imports are done by passing over the request body, creating an async record in the db,
and spawning a thread to do the import
* Adds a config option to enable zone import

Implements: async-import-export
APIImpact: Adds /zones/tasks/imports and removes import from admin api

Change-Id: Ib23810bf8b25d962b9d2d75e042bb097f3c12f7a
This commit is contained in:
Tim Simmons 2015-05-08 19:50:31 +00:00
parent 53a7300103
commit 021946e386
31 changed files with 1380 additions and 237 deletions

View File

@ -1,82 +0,0 @@
# COPYRIGHT 2015 Hewlett-Packard Development Company, L.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 dns import zone as dnszone
from dns import exception as dnsexception
import pecan
from oslo_log import log as logging
from oslo_config import cfg
from designate.api.v2.controllers import rest
from designate import dnsutils
from designate import exceptions
from designate.objects.adapters import DesignateAdapter
from designate import policy
LOG = logging.getLogger(__name__)
class ImportController(rest.RestController):
BASE_URI = cfg.CONF['service:api'].api_base_uri.rstrip('/')
@pecan.expose(template='json:', content_type='application/json')
def post_all(self):
request = pecan.request
response = pecan.response
context = pecan.request.environ['context']
policy.check('zone_import', context)
if request.content_type != 'text/dns':
raise exceptions.UnsupportedContentType(
'Content-type must be text/dns')
try:
dnspython_zone = dnszone.from_text(
request.body,
# Don't relativize, otherwise we end up with '@' record names.
relativize=False,
# Dont check origin, we allow missing NS records (missing SOA
# records are taken care of in _create_zone).
check_origin=False)
domain = dnsutils.from_dnspython_zone(dnspython_zone)
domain.type = 'PRIMARY'
for rrset in list(domain.recordsets):
if rrset.type in ('NS', 'SOA'):
domain.recordsets.remove(rrset)
except dnszone.UnknownOrigin:
raise exceptions.BadRequest('The $ORIGIN statement is required and'
' must be the first statement in the'
' zonefile.')
except dnsexception.SyntaxError:
raise exceptions.BadRequest('Malformed zonefile.')
zone = self.central_api.create_domain(context, domain)
if zone['status'] == 'PENDING':
response.status_int = 202
else:
response.status_int = 201
zone = DesignateAdapter.render('API_v2', zone, request=request)
zone['links']['self'] = '%s/%s/%s' % (
self.BASE_URI, 'v2/zones', zone['id'])
response.headers['Location'] = zone['links']['self']
return zone

View File

@ -15,7 +15,6 @@
from oslo_log import log as logging
from designate.api.v2.controllers import rest
from designate.api.admin.controllers.extensions import import_
from designate.api.admin.controllers.extensions import export
LOG = logging.getLogger(__name__)
@ -28,12 +27,6 @@ class ZonesController(rest.RestController):
return '.zones'
def __init__(self):
# Import is a keyword - so we have to do a setattr instead
setattr(self, 'import', import_.ImportController())
super(ZonesController, self).__init__()
# We cannot do an assignment as import is a keyword. it is done as part of
# the __init__() above
#
# import = import_.CountsController()
export = export.ExportController()

View File

@ -14,6 +14,7 @@
# License for the specific language governing permissions and limitations
# under the License.
from oslo_log import log as logging
from oslo_config import cfg
from designate.api.v2.controllers.zones.tasks.transfer_requests \
import TransferRequestsController as TRC
@ -21,7 +22,10 @@ from designate.api.v2.controllers.zones.tasks.transfer_accepts \
import TransferAcceptsController as TRA
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
CONF = cfg.CONF
LOG = logging.getLogger(__name__)
@ -31,3 +35,4 @@ class TasksController(object):
transfer_requests = TRC()
abandon = abandon.AbandonController()
xfr = XfrController()
imports = ZoneImportController()

View File

@ -0,0 +1,102 @@
# 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 utils
from designate.api.v2.controllers import rest
from designate.objects.adapters.api_v2.zone_import \
import ZoneImportAPIv2Adapter
LOG = logging.getLogger(__name__)
class ZoneImportController(rest.RestController):
SORT_KEYS = ['created_at', 'id', 'updated_at']
@pecan.expose(template='json:', content_type='application/json')
@utils.validate_uuid('import_id')
def get_one(self, import_id):
"""Get imports"""
request = pecan.request
context = request.environ['context']
return ZoneImportAPIv2Adapter.render(
'API_v2',
self.central_api.get_zone_import(
context, import_id),
request=request)
@pecan.expose(template='json:', content_type='application/json')
def get_all(self, **params):
"""List ZoneImports"""
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 ZoneImportAPIv2Adapter.render(
'API_v2',
self.central_api.find_zone_imports(
context, criterion, marker, limit, sort_key, sort_dir),
request=request)
@pecan.expose(template='json:', content_type='application/json')
def post_all(self):
"""Create ZoneImport"""
request = pecan.request
response = pecan.response
context = request.environ['context']
body = request.body
if request.content_type != 'text/dns':
raise exceptions.UnsupportedContentType(
'Content-type must be text/dns')
# Create the zone_import
zone_import = self.central_api.create_zone_import(
context, body)
response.status_int = 202
zone_import = ZoneImportAPIv2Adapter.render(
'API_v2', zone_import, request=request)
response.headers['Location'] = zone_import['links']['self']
# Prepare and return the response body
return zone_import
@pecan.expose(template='json:', content_type='application/json')
@utils.validate_uuid('zone_import_id')
def delete_one(self, zone_import_id):
"""Delete Zone"""
request = pecan.request
response = pecan.response
context = request.environ['context']
self.central_api.delete_zone_import(context, zone_import_id)
response.status_int = 204
return ''

View File

@ -48,14 +48,15 @@ class CentralAPI(object):
4.3 - Added Zone Transfer Methods
5.0 - Remove dead server code
5.1 - Add xfr_domain
5.2 - Add Zone Import methods
"""
RPC_API_VERSION = '5.1'
RPC_API_VERSION = '5.2'
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.1')
self.client = rpc.get_client(target, version_cap='5.2')
@classmethod
def get_instance(cls):
@ -494,5 +495,38 @@ class CentralAPI(object):
def xfr_domain(self, context, domain_id):
LOG.info(_LI("xfr_domain: Calling central's xfr_domain"))
cctxt = self.client.prepare(version='5.1')
cctxt = self.client.prepare(version='5.2')
return cctxt.call(context, 'xfr_domain', domain_id=domain_id)
# Zone Import Methods
def create_zone_import(self, context, request_body):
LOG.info(_LI("create_zone_import: Calling central's "
"create_zone_import."))
return self.client.call(context, 'create_zone_import',
request_body=request_body)
def find_zone_imports(self, context, criterion=None, marker=None,
limit=None, sort_key=None, sort_dir=None):
LOG.info(_LI("find_zone_imports: Calling central's "
"find_zone_imports."))
return self.client.call(context, 'find_zone_imports',
criterion=criterion, marker=marker,
limit=limit, sort_key=sort_key,
sort_dir=sort_dir)
def get_zone_import(self, context, zone_import_id):
LOG.info(_LI("get_zone_import: Calling central's get_zone_import."))
return self.client.call(context, 'get_zone_import',
zone_import_id=zone_import_id)
def update_zone_import(self, context, zone_import):
LOG.info(_LI("update_zone_import: Calling central's "
"update_zone_import."))
return self.client.call(context, 'update_zone_import',
zone_import=zone_import)
def delete_zone_import(self, context, zone_import_id):
LOG.info(_LI("delete_zone_import: Calling central's "
"delete_zone_import."))
return self.client.call(context, 'delete_zone_import',
zone_import_id=zone_import_id)

View File

@ -24,6 +24,9 @@ import string
import random
import time
from eventlet import tpool
from dns import zone as dnszone
from dns import exception as dnsexception
from oslo_config import cfg
import oslo_messaging as messaging
from oslo_log import log as logging
@ -36,6 +39,7 @@ from designate.i18n import _LC
from designate.i18n import _LW
from designate import context as dcontext
from designate import exceptions
from designate import dnsutils
from designate import network_api
from designate import objects
from designate import policy
@ -247,7 +251,7 @@ def notification(notification_type):
class Service(service.RPCService, service.Service):
RPC_API_VERSION = '5.1'
RPC_API_VERSION = '5.2'
target = messaging.Target(version=RPC_API_VERSION)
@ -865,6 +869,9 @@ class Service(service.RPCService, service.Service):
if domain.obj_attr_is_set('recordsets'):
for rrset in domain.recordsets:
# This allows eventlet to yield, as this looping operation
# can be very long-lived.
time.sleep(0)
self._create_recordset_in_storage(
context, domain, rrset, increment_serial=False)
@ -2452,3 +2459,126 @@ class Service(service.RPCService, service.Service):
return self.storage.delete_zone_transfer_accept(
context,
zone_transfer_accept_id)
# Zone Import Methods
@notification('dns.zone_import.create')
def create_zone_import(self, context, request_body):
target = {'tenant_id': context.tenant}
policy.check('create_zone_import', context, target)
values = {
'status': 'PENDING',
'message': None,
'domain_id': None,
'tenant_id': context.tenant,
'task_type': 'IMPORT'
}
zone_import = objects.ZoneTask(**values)
created_zone_import = self.storage.create_zone_task(context,
zone_import)
self.tg.add_thread(self._import_zone, context, created_zone_import,
request_body)
return created_zone_import
def _import_zone(self, context, zone_import, request_body):
def _import(self, context, zone_import, request_body):
# Dnspython needs a str instead of a unicode object
request_body = str(request_body)
domain = None
try:
dnspython_zone = dnszone.from_text(
request_body,
# Don't relativize, or we end up with '@' record names.
relativize=False,
# Dont check origin, we allow missing NS records
# (missing SOA records are taken care of in _create_zone).
check_origin=False)
domain = dnsutils.from_dnspython_zone(dnspython_zone)
domain.type = 'PRIMARY'
for rrset in list(domain.recordsets):
if rrset.type in ('NS', 'SOA'):
domain.recordsets.remove(rrset)
except dnszone.UnknownOrigin:
zone_import.message = ('The $ORIGIN statement is required and'
' must be the first statement in the'
' zonefile.')
zone_import.status = 'ERROR'
except dnsexception.SyntaxError:
zone_import.message = 'Malformed zonefile.'
zone_import.status = 'ERROR'
except exceptions.BadRequest:
zone_import.message = 'An SOA record is required.'
zone_import.status = 'ERROR'
except Exception:
zone_import.message = 'An undefined error occured.'
zone_import.status = 'ERROR'
return domain, zone_import
# Execute the import in a real Python thread
domain, zone_import = tpool.execute(_import, self, context,
zone_import, request_body)
# If the zone import was valid, create the domain
if zone_import.status != 'ERROR':
try:
zone = self.create_domain(context, domain)
zone_import.status = 'COMPLETE'
zone_import.domain_id = zone.id
zone_import.message = '%(name)s imported' % {'name':
zone.name}
except exceptions.DuplicateDomain:
zone_import.status = 'ERROR'
zone_import.message = 'Duplicate zone.'
except exceptions.InvalidTTL as e:
zone_import.status = 'ERROR'
zone_import.message = e.message
except Exception:
zone_import.message = 'An undefined error occured.'
zone_import.status = 'ERROR'
self.update_zone_import(context, zone_import)
def find_zone_imports(self, context, criterion=None, marker=None,
limit=None, sort_key=None, sort_dir=None):
target = {'tenant_id': context.tenant}
policy.check('find_zone_imports', context, target)
criterion = {
'task_type': 'IMPORT'
}
return self.storage.find_zone_tasks(context, criterion, marker,
limit, sort_key, sort_dir)
def get_zone_import(self, context, zone_import_id):
target = {'tenant_id': context.tenant}
policy.check('get_zone_import', context, target)
return self.storage.get_zone_task(context, zone_import_id)
@notification('dns.zone_import.update')
def update_zone_import(self, context, zone_import):
target = {
'tenant_id': zone_import.tenant_id,
}
policy.check('update_zone_import', context, target)
return self.storage.update_zone_task(context, zone_import)
@notification('dns.zone_import.delete')
@transaction
def delete_zone_import(self, context, zone_import_id):
target = {
'zone_import_id': zone_import_id,
'tenant_id': context.tenant
}
policy.check('delete_zone_import', context, target)
zone_import = self.storage.delete_zone_task(context, zone_import_id)
return zone_import

View File

@ -259,6 +259,10 @@ class DuplicatePoolNsRecord(Duplicate):
error_type = 'duplicate_pool_ns_record'
class DuplicateZoneTask(Duplicate):
error_type = 'duplicate_zone_task'
class MethodNotAllowed(Base):
expected = True
error_code = 405
@ -343,6 +347,10 @@ class ZoneTransferAcceptNotFound(NotFound):
error_type = 'zone_transfer_accept_not_found'
class ZoneTaskNotFound(NotFound):
error_type = 'zone_task_not_found'
class LastServerDeleteNotAllowed(BadRequest):
error_type = 'last_server_delete_not_allowed'

View File

@ -42,6 +42,7 @@ from designate.objects.validation_error import ValidationError # noqa
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_task import ZoneTask, ZoneTaskList # noqa
# Record Types

View File

@ -28,3 +28,4 @@ from designate.objects.adapters.api_v2.quota import QuotaAPIv2Adapter, QuotaList
from designate.objects.adapters.api_v2.zone_transfer_accept import ZoneTransferAcceptAPIv2Adapter, ZoneTransferAcceptListAPIv2Adapter # noqa
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

View File

@ -47,7 +47,8 @@ class APIv2Adapter(base.DesignateAdapter):
# Check if we should include metadata
if isinstance(list_object, obj_base.PagedListObjectMixin):
metadata = {}
metadata['total_count'] = list_object.total_count
if list_object.total_count is not None:
metadata['total_count'] = list_object.total_count
r_list['metadata'] = metadata
return r_list

View File

@ -0,0 +1,71 @@
# 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 ZoneImportAPIv2Adapter(base.APIv2Adapter):
ADAPTER_OBJECT = objects.ZoneTask
MODIFICATIONS = {
'fields': {
"id": {},
"status": {},
"message": {},
"zone_id": {
'rename': 'domain_id',
},
"project_id": {
'rename': 'tenant_id'
},
"created_at": {},
"updated_at": {},
"version": {},
},
'options': {
'links': True,
'resource_name': 'import',
'collection_name': 'imports',
}
}
@classmethod
def _render_object(cls, object, *args, **kwargs):
obj = super(ZoneImportAPIv2Adapter, cls)._render_object(
object, *args, **kwargs)
if obj['zone_id'] is not None:
obj['links']['zone'] = \
'%s/v2/%s/%s' % (cls.BASE_URI, 'zones', obj['zone_id'])
return obj
class ZoneImportListAPIv2Adapter(base.APIv2Adapter):
ADAPTER_OBJECT = objects.ZoneTaskList
MODIFICATIONS = {
'options': {
'links': True,
'resource_name': 'import',
'collection_name': 'imports',
}
}

View File

@ -0,0 +1,61 @@
# 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 ZoneTask(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": ["IMPORT"],
},
'read_only': True
},
'tenant_id': {
'schema': {
'type': 'string',
},
'read_only': True
},
'message': {
'schema': {
'type': ['string', 'null'],
'maxLength': 160
},
'read_only': True
},
'domain_id': {
'schema': {
"type": "string",
"format": "uuid"
},
'read_only': True
},
}
class ZoneTaskList(base.ListObjectMixin, base.DesignateObject,
base.PagedListObjectMixin):
LIST_ITEM_TYPE = ZoneTask

View File

@ -630,6 +630,67 @@ class Storage(DriverPlugin):
:param pool_attribute_id: The ID of the PoolAttribute to be deleted
"""
@abc.abstractmethod
def create_zone_task(self, context, zone_task):
"""
Create a Zone Task.
:param context: RPC Context.
:param zone_task: Tld object with the values to be created.
"""
@abc.abstractmethod
def get_zone_task(self, context, zone_task_id):
"""
Get a Zone Task via ID.
:param context: RPC Context.
:param zone_task_id: Zone Task ID to get.
"""
@abc.abstractmethod
def find_zone_tasks(self, context, criterion=None, marker=None,
limit=None, sort_key=None, sort_dir=None):
"""
Find Zone Tasks
: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_task(self, context, criterion):
"""
Find a single Zone Task.
:param context: RPC Context.
:param criterion: Criteria to filter by.
"""
@abc.abstractmethod
def update_zone_task(self, context, zone_task):
"""
Update a Zone Task
:param context: RPC Context.
:param zone_task: Zone Task to update.
"""
@abc.abstractmethod
def delete_zone_task(self, context, zone_task_id):
"""
Delete a Zone Task via ID.
:param context: RPC Context.
:param zone_task_id: Delete a Zone Task via ID
"""
def ping(self, context):
"""Ping the Storage connection"""
return {

View File

@ -1122,6 +1122,43 @@ class SQLAlchemyStorage(sqlalchemy_base.SQLAlchemy, storage_base.Storage):
zone_transfer_accept,
exceptions.ZoneTransferAcceptNotFound)
# Zone Task Methods
def _find_zone_tasks(self, context, criterion, one=False, marker=None,
limit=None, sort_key=None, sort_dir=None):
return self._find(
context, tables.zone_tasks, objects.ZoneTask,
objects.ZoneTaskList, exceptions.ZoneTaskNotFound, criterion,
one, marker, limit, sort_key, sort_dir)
def create_zone_task(self, context, zone_task):
return self._create(
tables.zone_tasks, zone_task, exceptions.DuplicateZoneTask)
def get_zone_task(self, context, zone_task_id):
return self._find_zone_tasks(context, {'id': zone_task_id},
one=True)
def find_zone_tasks(self, context, criterion=None, marker=None,
limit=None, sort_key=None, sort_dir=None):
return self._find_zone_tasks(context, criterion, marker=marker,
limit=limit, sort_key=sort_key,
sort_dir=sort_dir)
def find_zone_task(self, context, criterion):
return self._find_zone_tasks(context, criterion, one=True)
def update_zone_task(self, context, zone_task):
return self._update(
context, tables.zone_tasks, zone_task,
exceptions.DuplicateZoneTask, exceptions.ZoneTaskNotFound)
def delete_zone_task(self, context, zone_task_id):
# Fetch the existing zone_task, we'll need to return it.
zone_task = self._find_zone_tasks(context, {'id': zone_task_id},
one=True)
return self._delete(context, tables.zone_tasks, zone_task,
exceptions.ZoneTaskNotFound)
# diagnostics
def ping(self, context):
start_time = time.time()

View File

@ -0,0 +1,60 @@
# 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 Integer, String, DateTime, Enum
from sqlalchemy.schema import Table, Column, MetaData
from oslo_utils import timeutils
from designate import utils
from designate.sqlalchemy.types import UUID
meta = MetaData()
TASK_STATUSES = ['ACTIVE', 'PENDING', 'DELETED', 'ERROR', 'COMPLETE']
TASK_TYPES = ['IMPORT']
zone_tasks_table = Table('zone_tasks', meta,
Column('id', UUID(), default=utils.generate_uuid, primary_key=True),
Column('created_at', DateTime, default=lambda: timeutils.utcnow()),
Column('updated_at', DateTime, onupdate=lambda: timeutils.utcnow()),
Column('version', Integer(), default=1, nullable=False),
Column('tenant_id', String(36), default=None, nullable=True),
Column('domain_id', UUID(), nullable=True),
Column('task_type', Enum(name='task_types', *TASK_TYPES), nullable=True),
Column('message', String(160), nullable=True),
Column('status', Enum(name='resource_statuses', *TASK_STATUSES),
nullable=False, server_default='ACTIVE',
default='ACTIVE'),
mysql_engine='INNODB',
mysql_charset='utf8')
def upgrade(migrate_engine):
meta.bind = migrate_engine
# Create the table
zone_tasks_table.create()
def downgrade(migrate_engine):
meta.bind = migrate_engine
# Find the table and drop it
zone_tasks_table = Table('zone_tasks', meta, autoload=True)
zone_tasks_table.drop()

View File

@ -39,6 +39,7 @@ ACTIONS = ['CREATE', 'DELETE', 'UPDATE', 'NONE']
ZONE_ATTRIBUTE_KEYS = ('master',)
ZONE_TYPES = ('PRIMARY', 'SECONDARY',)
ZONE_TASK_TYPES = ['IMPORT']
metadata = MetaData()
@ -307,3 +308,21 @@ zone_transfer_accepts = Table('zone_transfer_accepts', metadata,
mysql_engine='InnoDB',
mysql_charset='utf8',
)
zone_tasks = Table('zone_tasks', metadata,
Column('id', UUID(), default=utils.generate_uuid, primary_key=True),
Column('created_at', DateTime, default=lambda: timeutils.utcnow()),
Column('updated_at', DateTime, onupdate=lambda: timeutils.utcnow()),
Column('version', Integer(), default=1, nullable=False),
Column('tenant_id', String(36), default=None, nullable=True),
Column('domain_id', UUID(), nullable=True),
Column('task_type', Enum(name='task_types', *ZONE_TASK_TYPES),
nullable=True),
Column('message', String(160), nullable=True),
Column('status', Enum(name='resource_statuses', *TASK_STATUSES),
nullable=False, server_default='ACTIVE',
default='ACTIVE'),
mysql_engine='INNODB',
mysql_charset='utf8')

View File

@ -17,6 +17,7 @@ import copy
import functools
import os
import inspect
import time
from testtools import testcase
from oslotest import base
@ -237,6 +238,23 @@ class TestCase(base.BaseTestCase):
"target_tenant_id": "target_tenant_id"
}]
zone_task_fixtures = [{
'status': 'PENDING',
'domain_id': None,
'message': None,
'task_type': 'IMPORT'
}, {
'status': 'ERROR',
'domain_id': None,
'message': None,
'task_type': 'IMPORT'
}, {
'status': 'COMPLETE',
'domain_id': '6ca6baef-3305-4ad0-a52b-a82df5752b62',
'message': None,
'task_type': 'IMPORT'
}]
def setUp(self):
super(TestCase, self).setUp()
@ -503,6 +521,13 @@ class TestCase(base.BaseTestCase):
_values.update(values)
return _values
def get_zone_task_fixture(self, fixture=0, values=None):
values = values or {}
_values = copy.copy(self.zone_task_fixtures[fixture])
_values.update(values)
return _values
def create_tld(self, **kwargs):
context = kwargs.pop('context', self.admin_context)
fixture = kwargs.pop('fixture', 0)
@ -646,6 +671,44 @@ class TestCase(base.BaseTestCase):
return self.central_service.create_zone_transfer_accept(
context, objects.ZoneTransferAccept.from_dict(values))
def create_zone_task(self, **kwargs):
context = kwargs.pop('context', self.admin_context)
fixture = kwargs.pop('fixture', 0)
zone_task = self.get_zone_task_fixture(fixture=fixture,
values=kwargs)
return self.storage.create_zone_task(
context, objects.ZoneTask.from_dict(zone_task))
def wait_for_import(self, zone_import_id, errorok=False):
"""
Zone imports spawn a thread to parse the zone file and
insert the data. This waits for this process before continuing
"""
attempts = 0
while attempts < 20:
# Give the import a half second to complete
time.sleep(.5)
# Retrieve it, and ensure it's the same
zone_import = self.central_service.get_zone_import(
self.admin_context, zone_import_id)
# If the import is done, we're done
if zone_import.status == 'COMPLETE':
break
# If errors are allowed, just make sure that something completed
if errorok:
if zone_import.status != 'PENDING':
break
attempts += 1
if not errorok:
self.assertEqual(zone_import.status, 'COMPLETE')
def _ensure_interface(self, interface, implementation):
for name in interface.__abstractmethods__:
in_arginfo = inspect.getargspec(getattr(interface, name))

View File

@ -0,0 +1,21 @@
$ORIGIN example2.com.
example2.com. 600 IN SOA ns1.example2.com. nsadmin.example2.com. (
2013091101 ; serial
7200 ; refresh
3600 ; retry
2419200 ; expire
10800 ; minimum
)
ipv4.example2.com. 300 IN A 192.0.0.1
ipv6.example2.com. IN AAAA fd00::1
cname.example2.com. IN CNAME example2.com.
example2.com. IN MX 5 192.0.0.2
example2.com. IN MX 10 192.0.0.3
_http._tcp.example2.com. IN SRV 10 0 80 192.0.0.4
_http._tcp.example2.com. IN SRV 10 5 80 192.0.0.5
example2.com. IN TXT "abc" "def"
example2.com. IN SPF "v=spf1 mx a"
example2.com. IN NS ns1.example2.com.
example2.com. IN NS ns2.example2.com.
delegation.example2.com. IN NS ns1.example2.com.
1.0.0.192.in-addr.arpa. IN PTR ipv4.example2.com.

View File

@ -1,81 +0,0 @@
# COPYRIGHT 2015 Hewlett-Packard Development Company, L.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 dns import zone as dnszone
from oslo_config import cfg
from designate.tests.test_api.test_admin import AdminApiTestCase
cfg.CONF.import_opt('enabled_extensions_admin', 'designate.api.admin',
group='service:api')
class AdminApiZoneImportExportTest(AdminApiTestCase):
def setUp(self):
self.config(enabled_extensions_admin=['zones'], group='service:api')
super(AdminApiZoneImportExportTest, self).setUp()
# Zone import/export
def test_missing_origin(self):
self.policy({'zone_import': '@'})
fixture = self.get_zonefile_fixture(variant='noorigin')
self._assert_exception('bad_request', 400, self.client.post,
'/zones/import',
fixture, headers={'Content-type': 'text/dns'})
def test_missing_soa(self):
self.policy({'zone_import': '@'})
fixture = self.get_zonefile_fixture(variant='nosoa')
self._assert_exception('bad_request', 400, self.client.post,
'/zones/import',
fixture, headers={'Content-type': 'text/dns'})
def test_malformed_zonefile(self):
self.policy({'zone_import': '@'})
fixture = self.get_zonefile_fixture(variant='malformed')
self._assert_exception('bad_request', 400, self.client.post,
'/zones/import',
fixture, headers={'Content-type': 'text/dns'})
def test_import_export(self):
self.policy({'default': '@'})
# Since v2 doesn't support getting records, import and export the
# fixture, making sure they're the same according to dnspython
post_response = self.client.post('/zones/import',
self.get_zonefile_fixture(),
headers={'Content-type': 'text/dns'})
get_response = self.client.get('/zones/export/%s' %
post_response.json['id'],
headers={'Accept': 'text/dns'})
exported_zonefile = get_response.body
imported = dnszone.from_text(self.get_zonefile_fixture())
exported = dnszone.from_text(exported_zonefile)
# Compare SOA emails, since zone comparison takes care of origin
imported_soa = imported.get_rdataset(imported.origin, 'SOA')
imported_email = imported_soa[0].rname.to_text()
exported_soa = exported.get_rdataset(exported.origin, 'SOA')
exported_email = exported_soa[0].rname.to_text()
self.assertEqual(imported_email, exported_email)
# Delete SOAs since they have, at the very least, different serials,
# and dnspython considers that to be not equal.
imported.delete_rdataset(imported.origin, 'SOA')
exported.delete_rdataset(exported.origin, 'SOA')
# Delete NS records, since they won't be the same
imported.delete_rdataset(imported.origin, 'NS')
exported.delete_rdataset(exported.origin, 'NS')
imported.delete_rdataset('delegation', 'NS')
self.assertEqual(imported, exported)

View File

@ -0,0 +1,131 @@
# COPYRIGHT 2015 Hewlett-Packard Development Company, L.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 dns import zone as dnszone
from webtest import TestApp
from oslo_config import cfg
from designate.api import admin as admin_api
from designate.api import middleware
from designate.tests.test_api.test_v2 import ApiV2TestCase
cfg.CONF.import_opt('enabled_extensions_admin', 'designate.api.admin',
group='service:api')
class APIV2ZoneImportExportTest(ApiV2TestCase):
def setUp(self):
super(APIV2ZoneImportExportTest, self).setUp()
self.config(enable_api_admin=True, group='service:api')
self.config(enabled_extensions_admin=['zones'], group='service:api')
# Create the application
adminapp = admin_api.factory({})
# Inject the NormalizeURIMiddleware middleware
adminapp = middleware.NormalizeURIMiddleware(adminapp)
# Inject the FaultWrapper middleware
adminapp = middleware.FaultWrapperMiddleware(adminapp)
# Inject the TestContext middleware
adminapp = middleware.TestContextMiddleware(
adminapp, self.admin_context.tenant,
self.admin_context.tenant)
# Obtain a test client
self.adminclient = TestApp(adminapp)
# # Zone import/export
def test_missing_origin(self):
fixture = self.get_zonefile_fixture(variant='noorigin')
response = self.client.post_json('/zones/tasks/imports', fixture,
headers={'Content-type': 'text/dns'})
import_id = response.json_body['id']
self.wait_for_import(import_id, errorok=True)
url = '/zones/tasks/imports/%s' % import_id
response = self.client.get(url)
self.assertEqual(response.json['status'], 'ERROR')
origin_msg = ("The $ORIGIN statement is required and must be the"
" first statement in the zonefile.")
self.assertEqual(response.json['message'], origin_msg)
def test_missing_soa(self):
fixture = self.get_zonefile_fixture(variant='nosoa')
response = self.client.post_json('/zones/tasks/imports', fixture,
headers={'Content-type': 'text/dns'})
import_id = response.json_body['id']
self.wait_for_import(import_id, errorok=True)
url = '/zones/tasks/imports/%s' % import_id
response = self.client.get(url)
self.assertEqual(response.json['status'], 'ERROR')
origin_msg = ("Malformed zonefile.")
self.assertEqual(response.json['message'], origin_msg)
def test_malformed_zonefile(self):
fixture = self.get_zonefile_fixture(variant='malformed')
response = self.client.post_json('/zones/tasks/imports', fixture,
headers={'Content-type': 'text/dns'})
import_id = response.json_body['id']
self.wait_for_import(import_id, errorok=True)
url = '/zones/tasks/imports/%s' % import_id
response = self.client.get(url)
self.assertEqual(response.json['status'], 'ERROR')
origin_msg = ("Malformed zonefile.")
self.assertEqual(response.json['message'], origin_msg)
def test_import_export(self):
# Since v2 doesn't support getting records, import and export the
# fixture, making sure they're the same according to dnspython
post_response = self.client.post('/zones/tasks/imports',
self.get_zonefile_fixture(),
headers={'Content-type': 'text/dns'})
import_id = post_response.json_body['id']
self.wait_for_import(import_id)
url = '/zones/tasks/imports/%s' % import_id
response = self.client.get(url)
self.policy({'zone_export': '@'})
get_response = self.adminclient.get('/zones/export/%s' %
response.json['zone_id'],
headers={'Accept': 'text/dns'})
exported_zonefile = get_response.body
imported = dnszone.from_text(self.get_zonefile_fixture())
exported = dnszone.from_text(exported_zonefile)
# Compare SOA emails, since zone comparison takes care of origin
imported_soa = imported.get_rdataset(imported.origin, 'SOA')
imported_email = imported_soa[0].rname.to_text()
exported_soa = exported.get_rdataset(exported.origin, 'SOA')
exported_email = exported_soa[0].rname.to_text()
self.assertEqual(imported_email, exported_email)
# Delete SOAs since they have, at the very least, different serials,
# and dnspython considers that to be not equal.
imported.delete_rdataset(imported.origin, 'SOA')
exported.delete_rdataset(exported.origin, 'SOA')
# Delete NS records, since they won't be the same
imported.delete_rdataset(imported.origin, 'NS')
exported.delete_rdataset(exported.origin, 'NS')
imported.delete_rdataset('delegation', 'NS')
self.assertEqual(imported, exported)

View File

@ -2913,3 +2913,112 @@ class CentralServiceTest(CentralTestCase):
zone_transfer_accept = \
self.central_service.create_zone_transfer_accept(
tenant_3_context, zone_transfer_accept)
# Zone Import Tests
def test_create_zone_import(self):
# Create a Zone Import
context = self.get_context()
request_body = self.get_zonefile_fixture()
zone_import = self.central_service.create_zone_import(context,
request_body)
# Ensure all values have been set correctly
self.assertIsNotNone(zone_import['id'])
self.assertEqual(zone_import.status, 'PENDING')
self.assertEqual(zone_import.message, None)
self.assertEqual(zone_import.domain_id, None)
self.wait_for_import(zone_import.id)
def test_find_zone_imports(self):
context = self.get_context()
# Ensure we have no zone_imports to start with.
zone_imports = self.central_service.find_zone_imports(
self.admin_context)
self.assertEqual(len(zone_imports), 0)
# Create a single zone_import
request_body = self.get_zonefile_fixture()
self.central_service.create_zone_import(context, request_body)
# Ensure we can retrieve the newly created zone_import
zone_imports = self.central_service.find_zone_imports(
self.admin_context)
self.assertEqual(len(zone_imports), 1)
# Create a second zone_import
request_body = self.get_zonefile_fixture(variant="two")
zone_import = self.central_service.create_zone_import(context,
request_body)
# Wait for the imports to complete
self.wait_for_import(zone_import.id)
# Ensure we can retrieve both zone_imports
zone_imports = self.central_service.find_zone_imports(
self.admin_context)
self.assertEqual(len(zone_imports), 2)
self.assertEqual(zone_imports[0].status, 'COMPLETE')
self.assertEqual(zone_imports[1].status, 'COMPLETE')
def test_get_zone_import(self):
# Create a Zone Import
context = self.get_context()
request_body = self.get_zonefile_fixture()
zone_import = self.central_service.create_zone_import(
context, request_body)
# Wait for the import to complete
# time.sleep(1)
self.wait_for_import(zone_import.id)
# Retrieve it, and ensure it's the same
zone_import = self.central_service.get_zone_import(
self.admin_context, zone_import.id)
self.assertEqual(zone_import['id'], zone_import.id)
self.assertEqual(zone_import['status'], zone_import.status)
self.assertEqual('COMPLETE', zone_import.status)
def test_update_zone_import(self):
# Create a Zone Import
context = self.get_context()
request_body = self.get_zonefile_fixture()
zone_import = self.central_service.create_zone_import(
context, request_body)
self.wait_for_import(zone_import.id)
# Update the Object
zone_import.message = 'test message'
# Perform the update
zone_import = self.central_service.update_zone_import(
self.admin_context, zone_import)
# Fetch the zone_import again
zone_import = self.central_service.get_zone_import(context,
zone_import.id)
# Ensure the zone_import was updated correctly
self.assertEqual('test message', zone_import.message)
def test_delete_zone_import(self):
# Create a Zone Import
context = self.get_context()
request_body = self.get_zonefile_fixture()
zone_import = self.central_service.create_zone_import(
context, request_body)
self.wait_for_import(zone_import.id)
# Delete the zone_import
self.central_service.delete_zone_import(context,
zone_import['id'])
# Fetch the zone_import again, ensuring an exception is raised
self.assertRaises(
exceptions.ZoneTaskNotFound,
self.central_service.get_zone_import,
context, zone_import['id'])

View File

@ -2346,3 +2346,123 @@ class StorageTestCase(object):
with testtools.ExpectedException(exceptions.DuplicatePoolAttribute):
self.create_pool_attribute(fixture=0)
# Zone Import Tests
def test_create_zone_task(self):
values = {
'status': 'PENDING',
'task_type': 'IMPORT'
}
result = self.storage.create_zone_task(
self.admin_context, objects.ZoneTask.from_dict(values))
self.assertIsNotNone(result['id'])
self.assertIsNotNone(result['created_at'])
self.assertIsNone(result['updated_at'])
self.assertIsNotNone(result['version'])
self.assertEqual(result['status'], values['status'])
self.assertEqual(result['domain_id'], None)
self.assertEqual(result['message'], None)
def test_find_zone_tasks(self):
actual = self.storage.find_zone_tasks(self.admin_context)
self.assertEqual(0, len(actual))
# Create a single ZoneTask
zone_task = self.create_zone_task(fixture=0)
actual = self.storage.find_zone_tasks(self.admin_context)
self.assertEqual(1, len(actual))
self.assertEqual(zone_task['status'], actual[0]['status'])
self.assertEqual(zone_task['message'], actual[0]['message'])
self.assertEqual(zone_task['domain_id'], actual[0]['domain_id'])
def test_find_zone_tasks_paging(self):
# Create 10 ZoneTasks
created = [self.create_zone_task() for i in xrange(10)]
# Ensure we can page through the results.
self._ensure_paging(created, self.storage.find_zone_tasks)
def test_find_zone_tasks_with_criterion(self):
zone_task_one = self.create_zone_task(fixture=0)
zone_task_two = self.create_zone_task(fixture=1)
criterion_one = dict(status=zone_task_one['status'])
results = self.storage.find_zone_tasks(self.admin_context,
criterion_one)
self.assertEqual(len(results), 1)
self.assertEqual(results[0]['status'], zone_task_one['status'])
criterion_two = dict(status=zone_task_two['status'])
results = self.storage.find_zone_tasks(self.admin_context,
criterion_two)
self.assertEqual(len(results), 1)
self.assertEqual(results[0]['status'], zone_task_two['status'])
def test_get_zone_task(self):
# Create a zone_task
expected = self.create_zone_task()
actual = self.storage.get_zone_task(self.admin_context,
expected['id'])
self.assertEqual(actual['status'], expected['status'])
def test_get_zone_task_missing(self):
with testtools.ExpectedException(exceptions.ZoneTaskNotFound):
uuid = '4c8e7f82-3519-4bf7-8940-a66a4480f223'
self.storage.get_zone_task(self.admin_context, uuid)
def test_find_zone_task_criterion_missing(self):
expected = self.create_zone_task()
criterion = dict(status=expected['status'] + "NOT FOUND")
with testtools.ExpectedException(exceptions.ZoneTaskNotFound):
self.storage.find_zone_task(self.admin_context, criterion)
def test_update_zone_task(self):
# Create a zone_task
zone_task = self.create_zone_task(status='PENDING', task_type='IMPORT')
# Update the zone_task
zone_task.status = 'COMPLETE'
# Update storage
zone_task = self.storage.update_zone_task(self.admin_context,
zone_task)
# Verify the new value
self.assertEqual('COMPLETE', zone_task.status)
# Ensure the version column was incremented
self.assertEqual(2, zone_task.version)
def test_update_zone_task_missing(self):
zone_task = objects.ZoneTask(
id='486f9cbe-b8b6-4d8c-8275-1a6e47b13e00')
with testtools.ExpectedException(exceptions.ZoneTaskNotFound):
self.storage.update_zone_task(self.admin_context, zone_task)
def test_delete_zone_task(self):
# Create a zone_task
zone_task = self.create_zone_task()
# Delete the zone_task
self.storage.delete_zone_task(self.admin_context, zone_task['id'])
# Verify that it's deleted
with testtools.ExpectedException(exceptions.ZoneTaskNotFound):
self.storage.get_zone_task(self.admin_context, zone_task['id'])
def test_delete_zone_task_missing(self):
with testtools.ExpectedException(exceptions.ZoneTaskNotFound):
uuid = 'cac1fc02-79b2-4e62-a1a4-427b6790bbe6'
self.storage.delete_zone_task(self.admin_context, uuid)

View File

@ -88,7 +88,6 @@ V2 API
rest/v2/recordsets
rest/v2/tlds
rest/v2/blacklists
rest/v2/quotas
rest/v2/pools
Admin API
@ -98,4 +97,5 @@ Admin API
:glob:
rest/admin/quotas
rest/admin/zones

View File

@ -3,7 +3,7 @@ Zones
Overview
--------
The zones extension can be used to import and export zonesfiles to designate.
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
@ -57,58 +57,3 @@ Export Zone
:statuscode 406: Not Acceptable
Notice how the SOA and NS records are replaced with the Designate server(s).
Import Zone
-----------
.. http:post:: /admin/zones/import
To import a zonefile, set the Content-type to **text/dns** . The
**zoneextractor.py** tool in the **contrib** folder can generate zonefiles
that are suitable for Designate (without any **$INCLUDE** statements for
example).
**Example request:**
.. sourcecode:: http
POST /admin/zones/import HTTP/1.1
Host: 127.0.0.1:9001
Content-type: text/dns
$ORIGIN example.com.
example.com. 42 IN SOA ns.example.com. nsadmin.example.com. 42 42 42 42 42
example.com. 42 IN NS ns.example.com.
example.com. 42 IN MX 10 mail.example.com.
ns.example.com. 42 IN A 10.0.0.1
mail.example.com. 42 IN A 10.0.0.2
**Example response:**
.. sourcecode:: http
HTTP/1.1 201 Created
Content-Type: application/json
{
"email": "nsadmin@example.com",
"id": "6b78734a-aef1-45cd-9708-8eb3c2d26ff1",
"links": {
"self": "http://127.0.0.1:9001/v2/zones/6b78734a-aef1-45cd-9708-8eb3c2d26ff1"
},
"name": "example.com.",
"pool_id": "572ba08c-d929-4c70-8e42-03824bb24ca2",
"project_id": "d7accc2f8ce343318386886953f2fc6a",
"serial": 1404757531,
"ttl": "42",
"created_at": "2014-07-07T18:25:31.275934",
"updated_at": null,
"version": 1,
"masters": [],
"type": "PRIMARY",
"transferred_at": null
}
:statuscode 201: Created
:statuscode 415: Unsupported Media Type
:statuscode 400: Bad request

View File

@ -559,3 +559,192 @@ Accept a Transfer Request
"status": "COMPLETE"
}
Import Zone
-----------
Create a Zone Import
^^^^^^^^^^^^^^^^^^^^
.. http:post:: /zones/tasks/imports
To import a zonefile, set the Content-type to **text/dns** . The
**zoneextractor.py** tool in the **contrib** folder can generate zonefiles
that are suitable for Designate (without any **$INCLUDE** statements for
example).
An object will be returned that can be queried using the 'self' link the
'links' field.
**Example request:**
.. sourcecode:: http
POST /v2/zones/tasks/imports HTTP/1.1
Host: 127.0.0.1:9001
Content-type: text/dns
$ORIGIN example.com.
example.com. 42 IN SOA ns.example.com. nsadmin.example.com. 42 42 42 42 42
example.com. 42 IN NS ns.example.com.
example.com. 42 IN MX 10 mail.example.com.
ns.example.com. 42 IN A 10.0.0.1
mail.example.com. 42 IN A 10.0.0.2
**Example response:**
.. sourcecode:: http
HTTP/1.1 201 Created
Content-Type: application/json
{
"status": "PENDING",
"zone_id": null,
"links": {
"self": "http://127.0.0.1:9001/v2/zones/tasks/imports/074e805e-fe87-4cbb-b10b-21a06e215d41"
},
"created_at": "2015-05-08T15:43:42.000000",
"updated_at": null,
"version": 1,
"message": null,
"project_id": "1",
"id": "074e805e-fe87-4cbb-b10b-21a06e215d41"
}
:statuscode 202: Accepted
:statuscode 415: Unsupported Media Type
View a Zone Import
^^^^^^^^^^^^^^^^^^
.. http:get:: /zones/tasks/imports/(uuid:id)
The status of a zone import can be viewed by querying the id
given when the request was created.
**Example request:**
.. sourcecode:: http
GET /v2/zones/tasks/imports/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/imports/074e805e-fe87-4cbb-b10b-21a06e215d41",
"href": "http://127.0.0.1:9001/v2/zones/6625198b-d67d-47dc-8d29-f90bd60f3ac4"
},
"created_at": "2015-05-08T15:43:42.000000",
"updated_at": "2015-05-08T15:43:42.000000",
"version": 2,
"message": "example.com. imported",
"project_id": "noauth-project",
"id": "074e805e-fe87-4cbb-b10b-21a06e215d41"
}
:statuscode 200: Success
:statuscode 401: Access Denied
:statuscode 404: Not Found
Notice the status has been updated, the message field shows that the zone was
successfully imported, and there is now a 'href' in the 'links' field that points
to the new zone.
List Zone Imports
^^^^^^^^^^^^^^^^^
.. http:get:: /zones/tasks/imports/
List all of the zone imports created by this project.
**Example request:**
.. sourcecode:: http
GET /v2/zones/tasks/imports/ 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
{
"imports": [
{
"status": "COMPLETE",
"zone_id": "ea2fd415-dc6d-401c-a8af-90a89d7efcf9",
"links": {
"self": "http://127.0.0.1:9001/v2/zones/tasks/imports/fb47a23e-eb97-4c86-a3d4-f3e1a4ca9f5e",
"href": "http://127.0.0.1:9001/v2/zones/ea2fd415-dc6d-401c-a8af-90a89d7efcf9"
},
"created_at": "2015-05-08T15:22:50.000000",
"updated_at": "2015-05-08T15:22:50.000000",
"version": 2,
"message": "example.com. imported",
"project_id": "noauth-project",
"id": "fb47a23e-eb97-4c86-a3d4-f3e1a4ca9f5e"
},
{
"status": "COMPLETE",
"zone_id": "6625198b-d67d-47dc-8d29-f90bd60f3ac4",
"links": {
"self": "http://127.0.0.1:9001/v2/zones/tasks/imports/074e805e-fe87-4cbb-b10b-21a06e215d41",
"href": "http://127.0.0.1:9001/v2/zones/6625198b-d67d-47dc-8d29-f90bd60f3ac4"
},
"created_at": "2015-05-08T15:43:42.000000",
"updated_at": "2015-05-08T15:43:42.000000",
"version": 2,
"message": "example.com. imported",
"project_id": "noauth-project",
"id": "074e805e-fe87-4cbb-b10b-21a06e215d41"
}
],
"links": {
"self": "http://127.0.0.1:9001/v2/zones/tasks/imports"
}
}
:statuscode 200: Success
:statuscode 401: Access Denied
:statuscode 404: Not Found
Delete Zone Import
^^^^^^^^^^^^^^^^^^
.. http:delete:: /zones/tasks/imports/(uuid:id)
Deletes a zone import with the specified ID. This does not affect the zone
that was imported, it simply removes the record of the import.
**Example Request:**
.. sourcecode:: http
DELETE /v2/zones/tasks/imports/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

@ -126,7 +126,7 @@ debug = False
# Enabled Admin API extensions
# Can be one or more of : reports, quotas, counts, tenants, zones
# zone import / export is in zones extension
# zone export is in zones extension
#enabled_extensions_admin =
# Show the pecan HTML based debug interface (v2 only)

View File

@ -19,9 +19,6 @@
"use_low_ttl": "rule:admin",
"zone_import": "rule:admin",
"zone_export": "rule:admin",
"get_quotas": "rule:admin_or_owner",
"get_quota": "rule:admin_or_owner",
"set_quota": "rule:admin",
@ -109,5 +106,13 @@
"find_zone_transfer_accepts": "rule:admin",
"find_zone_transfer_accept": "rule:admin",
"update_zone_transfer_accept": "rule:admin",
"delete_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"
}

View File

@ -0,0 +1,62 @@
"""
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_import_model import ZoneImportModel
from functionaltests.api.v2.models.zone_import_model import ZoneImportListModel
from functionaltests.common.client import ClientMixin
from functionaltests.common import utils
class ZoneImportClient(ClientMixin):
@classmethod
def zone_imports_uri(cls):
return "/v2/zones/tasks/imports"
@classmethod
def zone_import_uri(cls, id):
return "{0}/{1}".format(cls.zone_imports_uri(), id)
def list_zone_imports(self, **kwargs):
resp, body = self.client.get(self.zone_imports_uri(), **kwargs)
return self.deserialize(resp, body, ZoneImportListModel)
def get_zone_import(self, id, **kwargs):
resp, body = self.client.get(self.zone_import_uri(id))
return self.deserialize(resp, body, ZoneImportModel)
def post_zone_import(self, zonefile_data, **kwargs):
headers = {'Content-Type': 'text/dns'}
resp, body = self.client.post(self.zone_imports_uri(),
body=zonefile_data, headers=headers, **kwargs)
return self.deserialize(resp, body, ZoneImportModel)
def delete_zone_import(self, id, **kwargs):
resp, body = self.client.delete(self.zone_import_uri(id), **kwargs)
return resp, body
def wait_for_zone_import(self, zone_import_id):
utils.wait_for_condition(
lambda: self.is_zone_import_active(zone_import_id))
def is_zone_import_active(self, zone_import_id):
resp, model = self.get_zone_import(zone_import_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 ZoneImportModel(BaseModel):
pass
class ZoneImportListModel(CollectionModel):
COLLECTION_NAME = 'imports'
MODEL_TYPE = ZoneImportModel

View File

@ -16,10 +16,12 @@ limitations under the License.
from tempest_lib.exceptions import Conflict
from tempest_lib.exceptions import Forbidden
from tempest_lib.exceptions import NotFound
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
class ZoneTest(DesignateV2Test):
@ -105,3 +107,36 @@ class ZoneOwnershipTest(DesignateV2Test):
self._create_zone(zone, user='default')
self.assertRaises(Forbidden,
lambda: self._create_zone(superzone, user='alt'))
class ZoneImportTest(DesignateV2Test):
def setUp(self):
super(ZoneImportTest, self).setUp()
def test_import_domain(self):
user = 'default'
import_client = ZoneImportClient.as_user(user)
zone_client = ZoneClient.as_user(user)
zonefile = datagen.random_zonefile_data()
resp, model = import_client.post_zone_import(
zonefile)
import_id = model.id
self.assertEqual(resp.status, 202)
self.assertEqual(model.status, 'PENDING')
import_client.wait_for_zone_import(import_id)
resp, model = import_client.get_zone_import(
model.id)
self.assertEqual(resp.status, 200)
self.assertEqual(model.status, 'COMPLETE')
# Wait for the zone to become 'ACTIVE'
zone_client.wait_for_zone(model.zone_id)
resp, zone_model = zone_client.get_zone(model.zone_id)
# Now make sure we can delete the zone_import
import_client.delete_zone_import(import_id)
self.assertRaises(NotFound,
lambda: import_client.get_zone_import(model.id))

View File

@ -120,3 +120,18 @@ def random_pool_data():
data["ns_records"] = []
return PoolModel.from_dict(data)
def random_zonefile_data(name=None, ttl=None):
"""Generate random zone data, with optional overrides
:return: A ZoneModel
"""
zone_base = ('$ORIGIN &\n& # IN SOA ns.& nsadmin.& # # # # #\n'
'& # IN NS ns.&\n& # IN MX 10 mail.&\nns.& 360 IN A 1.0.0.1')
if name is None:
name = random_string(prefix='testdomain', suffix='.com.')
if ttl is None:
ttl = str(random.randint(1200, 8400))
return zone_base.replace('&', name).replace('#', ttl)