diff --git a/designate/api/admin/controllers/extensions/import_.py b/designate/api/admin/controllers/extensions/import_.py deleted file mode 100644 index 3c37f6d8e..000000000 --- a/designate/api/admin/controllers/extensions/import_.py +++ /dev/null @@ -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 diff --git a/designate/api/admin/controllers/extensions/zones.py b/designate/api/admin/controllers/extensions/zones.py index f994c729e..aee4d92d6 100644 --- a/designate/api/admin/controllers/extensions/zones.py +++ b/designate/api/admin/controllers/extensions/zones.py @@ -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() diff --git a/designate/api/v2/controllers/zones/tasks/__init__.py b/designate/api/v2/controllers/zones/tasks/__init__.py index 58baa2663..34785503c 100644 --- a/designate/api/v2/controllers/zones/tasks/__init__.py +++ b/designate/api/v2/controllers/zones/tasks/__init__.py @@ -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() diff --git a/designate/api/v2/controllers/zones/tasks/imports.py b/designate/api/v2/controllers/zones/tasks/imports.py new file mode 100644 index 000000000..2d8c8e25f --- /dev/null +++ b/designate/api/v2/controllers/zones/tasks/imports.py @@ -0,0 +1,102 @@ +# Copyright 2015 Rackspace Inc. +# +# Author: Tim Simmons +# +# 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 '' diff --git a/designate/central/rpcapi.py b/designate/central/rpcapi.py index 03b94688d..2f619df9b 100644 --- a/designate/central/rpcapi.py +++ b/designate/central/rpcapi.py @@ -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) diff --git a/designate/central/service.py b/designate/central/service.py index a765e93cc..064fc69e4 100644 --- a/designate/central/service.py +++ b/designate/central/service.py @@ -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 diff --git a/designate/exceptions.py b/designate/exceptions.py index 5c71d7bd3..4a2ca2d54 100644 --- a/designate/exceptions.py +++ b/designate/exceptions.py @@ -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' diff --git a/designate/objects/__init__.py b/designate/objects/__init__.py index 5337d02d1..98f9c1237 100644 --- a/designate/objects/__init__.py +++ b/designate/objects/__init__.py @@ -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 diff --git a/designate/objects/adapters/__init__.py b/designate/objects/adapters/__init__.py index 5154f4768..c019b9771 100644 --- a/designate/objects/adapters/__init__.py +++ b/designate/objects/adapters/__init__.py @@ -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 diff --git a/designate/objects/adapters/api_v2/base.py b/designate/objects/adapters/api_v2/base.py index accf2b8d5..90abd3846 100644 --- a/designate/objects/adapters/api_v2/base.py +++ b/designate/objects/adapters/api_v2/base.py @@ -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 diff --git a/designate/objects/adapters/api_v2/zone_import.py b/designate/objects/adapters/api_v2/zone_import.py new file mode 100644 index 000000000..ee85a5b50 --- /dev/null +++ b/designate/objects/adapters/api_v2/zone_import.py @@ -0,0 +1,71 @@ +# Copyright 2015 Rackspace Inc. +# +# Author: Tim Simmons +# +# 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', + } + } diff --git a/designate/objects/zone_task.py b/designate/objects/zone_task.py new file mode 100644 index 000000000..350846548 --- /dev/null +++ b/designate/objects/zone_task.py @@ -0,0 +1,61 @@ +# Copyright 2015 Rackspace Inc. +# +# Author: Tim Simmons +# +# 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 diff --git a/designate/storage/base.py b/designate/storage/base.py index 4d6636be9..cd4e5da97 100644 --- a/designate/storage/base.py +++ b/designate/storage/base.py @@ -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 { diff --git a/designate/storage/impl_sqlalchemy/__init__.py b/designate/storage/impl_sqlalchemy/__init__.py index 62b54b705..9ee78a746 100644 --- a/designate/storage/impl_sqlalchemy/__init__.py +++ b/designate/storage/impl_sqlalchemy/__init__.py @@ -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() diff --git a/designate/storage/impl_sqlalchemy/migrate_repo/versions/067_zone_tasks.py b/designate/storage/impl_sqlalchemy/migrate_repo/versions/067_zone_tasks.py new file mode 100644 index 000000000..f5cdfb7e3 --- /dev/null +++ b/designate/storage/impl_sqlalchemy/migrate_repo/versions/067_zone_tasks.py @@ -0,0 +1,60 @@ +# Copyright 2015 Rackspace Inc. +# +# Author: Tim Simmons +# +# 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() diff --git a/designate/storage/impl_sqlalchemy/tables.py b/designate/storage/impl_sqlalchemy/tables.py index e6c98f21b..e62605f86 100644 --- a/designate/storage/impl_sqlalchemy/tables.py +++ b/designate/storage/impl_sqlalchemy/tables.py @@ -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') diff --git a/designate/tests/__init__.py b/designate/tests/__init__.py index c5b5fbf67..f284539ad 100644 --- a/designate/tests/__init__.py +++ b/designate/tests/__init__.py @@ -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)) diff --git a/designate/tests/resources/zonefiles/two_example.com.zone b/designate/tests/resources/zonefiles/two_example.com.zone new file mode 100644 index 000000000..8167267cf --- /dev/null +++ b/designate/tests/resources/zonefiles/two_example.com.zone @@ -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. diff --git a/designate/tests/test_api/test_admin/extensions/test_zones.py b/designate/tests/test_api/test_admin/extensions/test_zones.py deleted file mode 100644 index b028878b7..000000000 --- a/designate/tests/test_api/test_admin/extensions/test_zones.py +++ /dev/null @@ -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) diff --git a/designate/tests/test_api/test_v2/test_import_export.py b/designate/tests/test_api/test_v2/test_import_export.py new file mode 100644 index 000000000..b59460e9d --- /dev/null +++ b/designate/tests/test_api/test_v2/test_import_export.py @@ -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) diff --git a/designate/tests/test_central/test_service.py b/designate/tests/test_central/test_service.py index ab6739256..3932e783d 100644 --- a/designate/tests/test_central/test_service.py +++ b/designate/tests/test_central/test_service.py @@ -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']) diff --git a/designate/tests/test_storage/__init__.py b/designate/tests/test_storage/__init__.py index b7021bfc3..88866b57b 100644 --- a/designate/tests/test_storage/__init__.py +++ b/designate/tests/test_storage/__init__.py @@ -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) diff --git a/doc/source/rest.rst b/doc/source/rest.rst index 9a603c2e1..f923af1b1 100644 --- a/doc/source/rest.rst +++ b/doc/source/rest.rst @@ -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 diff --git a/doc/source/rest/admin/zones.rst b/doc/source/rest/admin/zones.rst index 1eee6d6c1..5b3829b40 100644 --- a/doc/source/rest/admin/zones.rst +++ b/doc/source/rest/admin/zones.rst @@ -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 diff --git a/doc/source/rest/v2/zones.rst b/doc/source/rest/v2/zones.rst index 3d75ecb45..d6ad09026 100644 --- a/doc/source/rest/v2/zones.rst +++ b/doc/source/rest/v2/zones.rst @@ -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 diff --git a/etc/designate/designate.conf.sample b/etc/designate/designate.conf.sample index 8add2b25b..d9859f9b2 100644 --- a/etc/designate/designate.conf.sample +++ b/etc/designate/designate.conf.sample @@ -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) diff --git a/etc/designate/policy.json b/etc/designate/policy.json index c259e884a..213891221 100644 --- a/etc/designate/policy.json +++ b/etc/designate/policy.json @@ -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" } diff --git a/functionaltests/api/v2/clients/zone_import_client.py b/functionaltests/api/v2/clients/zone_import_client.py new file mode 100644 index 000000000..a7c0f08be --- /dev/null +++ b/functionaltests/api/v2/clients/zone_import_client.py @@ -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 diff --git a/functionaltests/api/v2/models/zone_import_model.py b/functionaltests/api/v2/models/zone_import_model.py new file mode 100644 index 000000000..8940395a1 --- /dev/null +++ b/functionaltests/api/v2/models/zone_import_model.py @@ -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 diff --git a/functionaltests/api/v2/test_zone.py b/functionaltests/api/v2/test_zone.py index ff90e23fa..8295ace12 100644 --- a/functionaltests/api/v2/test_zone.py +++ b/functionaltests/api/v2/test_zone.py @@ -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)) diff --git a/functionaltests/common/datagen.py b/functionaltests/common/datagen.py index 7109eff03..f2f582330 100644 --- a/functionaltests/common/datagen.py +++ b/functionaltests/common/datagen.py @@ -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)