From 50d1b1553e36f5927181eed65637491b14acb660 Mon Sep 17 00:00:00 2001 From: TimSimmons Date: Thu, 27 Aug 2015 15:38:54 -0500 Subject: [PATCH] Asynchronous Zone Export Do the needful to move Zone Exports to an asynchronous resource in the v2 API, as discussed at the Austin 2015 summe mid-cycle * Make designate-zone-manager an RPC service, with a read-only connection to the database * Add a 'location' column to the zone_tasks table that stores a location (swift, URI) that is used to determine where the export will be made available to the user * Add all the infrastucture to make zone export resources live (objects, central, storage methods) * Add a quota on the size of allowed synchronous exports * Tests, docs THIS DOES NOT IMPLEMENT * Zone exports to Swift * Debateable: See the note in zone_manager/service.py about how the configuration and determination of future swift exports will work. ApiImpact Blueprint: async-export Change-Id: I1c168b10358164c3ca5be986b4d615df71062851 --- designate/__init__.py | 2 + designate/api/v2/controllers/rest.py | 5 + .../v2/controllers/zones/tasks/__init__.py | 6 + .../api/v2/controllers/zones/tasks/exports.py | 121 +++++++++ designate/central/rpcapi.py | 38 ++- designate/central/service.py | 72 +++++- designate/exceptions.py | 8 + designate/objects/__init__.py | 1 + designate/objects/adapters/__init__.py | 1 + .../objects/adapters/api_v2/zone_export.py | 81 +++++++ designate/objects/zone_export.py | 68 ++++++ designate/quota/__init__.py | 2 + designate/quota/base.py | 1 + designate/storage/base.py | 61 +++++ designate/storage/impl_sqlalchemy/__init__.py | 40 +++ .../versions/069_zone_tasks_location.py | 44 ++++ designate/storage/impl_sqlalchemy/tables.py | 3 +- designate/tests/test_quota/test_quota.py | 1 + .../tests/unit/test_central/test_basic.py | 109 +++++++++ designate/zone_manager/__init__.py | 6 +- designate/zone_manager/rpcapi.py | 72 ++++++ designate/zone_manager/service.py | 90 ++++++- doc/source/rest.rst | 1 - doc/source/rest/admin/zones.rst | 59 ----- doc/source/rest/v2/zones.rst | 229 ++++++++++++++++++ etc/designate/designate.conf.sample | 3 + etc/designate/policy.json | 11 +- .../api/v2/clients/zone_export_client.py | 71 ++++++ .../api/v2/models/zone_export_model.py | 27 +++ functionaltests/api/v2/test_zone.py | 33 +++ 30 files changed, 1197 insertions(+), 69 deletions(-) create mode 100644 designate/api/v2/controllers/zones/tasks/exports.py create mode 100644 designate/objects/adapters/api_v2/zone_export.py create mode 100644 designate/objects/zone_export.py create mode 100644 designate/storage/impl_sqlalchemy/migrate_repo/versions/069_zone_tasks_location.py create mode 100644 designate/zone_manager/rpcapi.py delete mode 100644 doc/source/rest/admin/zones.rst create mode 100644 functionaltests/api/v2/clients/zone_export_client.py create mode 100644 functionaltests/api/v2/models/zone_export_model.py diff --git a/designate/__init__.py b/designate/__init__.py index a7354e3cf..85a8970a7 100644 --- a/designate/__init__.py +++ b/designate/__init__.py @@ -47,6 +47,8 @@ cfg.CONF.register_opts([ cfg.StrOpt('mdns-topic', default='mdns', help='mDNS Topic'), cfg.StrOpt('pool-manager-topic', default='pool_manager', help='Pool Manager Topic'), + cfg.StrOpt('zone-manager-topic', default='zone_manager', + help='Zone Manager Topic'), # Default TTL cfg.IntOpt('default-ttl', default=3600), diff --git a/designate/api/v2/controllers/rest.py b/designate/api/v2/controllers/rest.py index 3bd5e348d..bb821da91 100644 --- a/designate/api/v2/controllers/rest.py +++ b/designate/api/v2/controllers/rest.py @@ -33,6 +33,7 @@ from oslo_log import log as logging from designate import exceptions from designate.central import rpcapi as central_rpcapi +from designate.zone_manager import rpcapi as zone_manager_rpcapi from designate.i18n import _ @@ -54,6 +55,10 @@ class RestController(pecan.rest.RestController): def central_api(self): return central_rpcapi.CentralAPI.get_instance() + @property + def zone_manager_api(self): + return zone_manager_rpcapi.ZoneManagerAPI.get_instance() + def _apply_filter_params(self, params, accepted_filters, criterion): invalid=[] for k in params: diff --git a/designate/api/v2/controllers/zones/tasks/__init__.py b/designate/api/v2/controllers/zones/tasks/__init__.py index 34785503c..755e02fd0 100644 --- a/designate/api/v2/controllers/zones/tasks/__init__.py +++ b/designate/api/v2/controllers/zones/tasks/__init__.py @@ -24,6 +24,10 @@ from designate.api.v2.controllers.zones.tasks import abandon from designate.api.v2.controllers.zones.tasks.xfr import XfrController from designate.api.v2.controllers.zones.tasks.imports \ import ZoneImportController +from designate.api.v2.controllers.zones.tasks.exports \ + import ZoneExportsController +from designate.api.v2.controllers.zones.tasks.exports \ + import ZoneExportCreateController CONF = cfg.CONF LOG = logging.getLogger(__name__) @@ -36,3 +40,5 @@ class TasksController(object): abandon = abandon.AbandonController() xfr = XfrController() imports = ZoneImportController() + exports = ZoneExportsController() + export = ZoneExportCreateController() diff --git a/designate/api/v2/controllers/zones/tasks/exports.py b/designate/api/v2/controllers/zones/tasks/exports.py new file mode 100644 index 000000000..d750358c7 --- /dev/null +++ b/designate/api/v2/controllers/zones/tasks/exports.py @@ -0,0 +1,121 @@ +# 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 policy +from designate import utils +from designate.api.v2.controllers import rest +from designate.objects.adapters.api_v2.zone_export \ + import ZoneExportAPIv2Adapter + + +LOG = logging.getLogger(__name__) + + +class ZoneExportController(rest.RestController): + + @pecan.expose(template=None, content_type='text/dns') + @utils.validate_uuid('export_id') + def get_all(self, export_id): + context = pecan.request.environ['context'] + policy.check('zone_export', context) + + export = self.central_api.get_zone_export(context, export_id) + + if export.location and export.location.startswith('designate://'): + return self.zone_manager_api.\ + render_zone(context, export['domain_id']) + else: + msg = 'Zone can not be exported synchronously' + raise exceptions.BadRequest(msg) + + +class ZoneExportCreateController(rest.RestController): + + @pecan.expose(template='json:', content_type='application/json') + @utils.validate_uuid('zone_id') + def post_all(self, zone_id): + """Create Zone Export""" + request = pecan.request + response = pecan.response + context = request.environ['context'] + + # Create the zone_export + zone_export = self.central_api.create_zone_export( + context, zone_id) + response.status_int = 202 + + zone_export = ZoneExportAPIv2Adapter.render( + 'API_v2', zone_export, request=request) + + response.headers['Location'] = zone_export['links']['self'] + return zone_export + + +class ZoneExportsController(rest.RestController): + + SORT_KEYS = ['created_at', 'id', 'updated_at'] + + export = ZoneExportController() + + @pecan.expose(template='json:', content_type='application/json') + @utils.validate_uuid('export_id') + def get_one(self, export_id): + """Get Zone Exports""" + + request = pecan.request + context = request.environ['context'] + + return ZoneExportAPIv2Adapter.render( + 'API_v2', + self.central_api.get_zone_export( + context, export_id), + request=request) + + @pecan.expose(template='json:', content_type='application/json') + def get_all(self, **params): + """List Zone Exports""" + request = pecan.request + context = request.environ['context'] + marker, limit, sort_key, sort_dir = utils.get_paging_params( + params, self.SORT_KEYS) + + # Extract any filter params. + accepted_filters = ('status', 'message', 'zone_id', ) + + criterion = self._apply_filter_params( + params, accepted_filters, {}) + + return ZoneExportAPIv2Adapter.render( + 'API_v2', + self.central_api.find_zone_exports( + context, criterion, marker, limit, sort_key, sort_dir), + request=request) + + @pecan.expose(template='json:', content_type='application/json') + @utils.validate_uuid('zone_export_id') + def delete_one(self, zone_export_id): + """Delete Zone Export""" + request = pecan.request + response = pecan.response + context = request.environ['context'] + + self.central_api.delete_zone_export(context, zone_export_id) + response.status_int = 204 + + return '' diff --git a/designate/central/rpcapi.py b/designate/central/rpcapi.py index 738e43217..a82862e9e 100644 --- a/designate/central/rpcapi.py +++ b/designate/central/rpcapi.py @@ -50,14 +50,15 @@ class CentralAPI(object): 5.1 - Add xfr_domain 5.2 - Add Zone Import methods 5.3 - Add Zone Export method + 5.4 - Add asynchronous Zone Export methods """ - RPC_API_VERSION = '5.3' + RPC_API_VERSION = '5.4' def __init__(self, topic=None): topic = topic if topic else cfg.CONF.central_topic target = messaging.Target(topic=topic, version=self.RPC_API_VERSION) - self.client = rpc.get_client(target, version_cap='5.3') + self.client = rpc.get_client(target, version_cap='5.4') @classmethod def get_instance(cls): @@ -535,3 +536,36 @@ class CentralAPI(object): "delete_zone_import.")) return self.client.call(context, 'delete_zone_import', zone_import_id=zone_import_id) + + # Zone Export Methods + def create_zone_export(self, context, zone_id): + LOG.info(_LI("create_zone_export: Calling central's " + "create_zone_export.")) + return self.client.call(context, 'create_zone_export', + zone_id=zone_id) + + def find_zone_exports(self, context, criterion=None, marker=None, + limit=None, sort_key=None, sort_dir=None): + LOG.info(_LI("find_zone_exports: Calling central's " + "find_zone_exports.")) + return self.client.call(context, 'find_zone_exports', + criterion=criterion, marker=marker, + limit=limit, sort_key=sort_key, + sort_dir=sort_dir) + + def get_zone_export(self, context, zone_export_id): + LOG.info(_LI("get_zone_export: Calling central's get_zone_export.")) + return self.client.call(context, 'get_zone_export', + zone_export_id=zone_export_id) + + def update_zone_export(self, context, zone_export): + LOG.info(_LI("update_zone_export: Calling central's " + "update_zone_export.")) + return self.client.call(context, 'update_zone_export', + zone_export=zone_export) + + def delete_zone_export(self, context, zone_export_id): + LOG.info(_LI("delete_zone_export: Calling central's " + "delete_zone_export.")) + return self.client.call(context, 'delete_zone_export', + zone_export_id=zone_export_id) diff --git a/designate/central/service.py b/designate/central/service.py index fff6d1b7e..6f99b18eb 100644 --- a/designate/central/service.py +++ b/designate/central/service.py @@ -50,6 +50,7 @@ from designate import utils from designate import storage from designate.mdns import rpcapi as mdns_rpcapi from designate.pool_manager import rpcapi as pool_manager_rpcapi +from designate.zone_manager import rpcapi as zone_manager_rpcapi LOG = logging.getLogger(__name__) @@ -259,7 +260,7 @@ def notification(notification_type): class Service(service.RPCService, service.Service): - RPC_API_VERSION = '5.3' + RPC_API_VERSION = '5.4' target = messaging.Target(version=RPC_API_VERSION) @@ -307,6 +308,10 @@ class Service(service.RPCService, service.Service): def pool_manager_api(self): return pool_manager_rpcapi.PoolManagerAPI.get_instance() + @property + def zone_manager_api(self): + return zone_manager_rpcapi.ZoneManagerAPI.get_instance() + def _is_valid_domain_name(self, context, domain_name): # Validate domain name length if len(domain_name) > cfg.CONF['service:central'].max_domain_name_len: @@ -2611,3 +2616,68 @@ class Service(service.RPCService, service.Service): zone_import = self.storage.delete_zone_import(context, zone_import_id) return zone_import + + # Zone Export Methods + @notification('dns.zone_export.create') + def create_zone_export(self, context, zone_id): + # Try getting the domain to ensure it exists + domain = self.storage.get_domain(context, zone_id) + + target = {'tenant_id': context.tenant} + policy.check('create_zone_export', context, target) + + values = { + 'status': 'PENDING', + 'message': None, + 'domain_id': zone_id, + 'tenant_id': context.tenant, + 'task_type': 'EXPORT' + } + zone_export = objects.ZoneExport(**values) + + created_zone_export = self.storage.create_zone_export(context, + zone_export) + + self.zone_manager_api.start_zone_export(context, domain, + created_zone_export) + + return created_zone_export + + def find_zone_exports(self, context, criterion=None, marker=None, + limit=None, sort_key=None, sort_dir=None): + target = {'tenant_id': context.tenant} + policy.check('find_zone_exports', context, target) + + criterion = { + 'task_type': 'EXPORT' + } + return self.storage.find_zone_exports(context, criterion, marker, + limit, sort_key, sort_dir) + + def get_zone_export(self, context, zone_export_id): + target = {'tenant_id': context.tenant} + policy.check('get_zone_export', context, target) + + return self.storage.get_zone_export(context, zone_export_id) + + @notification('dns.zone_export.update') + def update_zone_export(self, context, zone_export): + target = { + 'tenant_id': zone_export.tenant_id, + } + policy.check('update_zone_export', context, target) + + return self.storage.update_zone_export(context, zone_export) + + @notification('dns.zone_export.delete') + @transaction + def delete_zone_export(self, context, zone_export_id): + target = { + 'zone_export_id': zone_export_id, + 'tenant_id': context.tenant + } + policy.check('delete_zone_export', context, target) + + zone_export = self.storage.delete_zone_export(context, zone_export_id) + + return zone_export diff --git a/designate/exceptions.py b/designate/exceptions.py index d2f08b9bc..1d3a9d673 100644 --- a/designate/exceptions.py +++ b/designate/exceptions.py @@ -290,6 +290,10 @@ class DuplicateZoneImport(Duplicate): error_type = 'duplicate_zone_import' +class DuplicateZoneExport(Duplicate): + error_type = 'duplicate_zone_export' + + class MethodNotAllowed(Base): expected = True error_code = 405 @@ -382,6 +386,10 @@ class ZoneImportNotFound(NotFound): error_type = 'zone_import_not_found' +class ZoneExportNotFound(NotFound): + error_type = 'zone_export_not_found' + + class LastServerDeleteNotAllowed(BadRequest): error_type = 'last_server_delete_not_allowed' diff --git a/designate/objects/__init__.py b/designate/objects/__init__.py index a9d5cc8e7..68d40fc8b 100644 --- a/designate/objects/__init__.py +++ b/designate/objects/__init__.py @@ -44,6 +44,7 @@ from designate.objects.validation_error import ValidationErrorList # noqa from designate.objects.zone_transfer_request import ZoneTransferRequest, ZoneTransferRequestList # noqa from designate.objects.zone_transfer_accept import ZoneTransferAccept, ZoneTransferAcceptList # noqa from designate.objects.zone_import import ZoneImport, ZoneImportList # noqa +from designate.objects.zone_export import ZoneExport, ZoneExportList # noqa # Record Types diff --git a/designate/objects/adapters/__init__.py b/designate/objects/adapters/__init__.py index 9994543cf..8f56604a4 100644 --- a/designate/objects/adapters/__init__.py +++ b/designate/objects/adapters/__init__.py @@ -30,3 +30,4 @@ from designate.objects.adapters.api_v2.zone_transfer_accept import ZoneTransferA from designate.objects.adapters.api_v2.zone_transfer_request import ZoneTransferRequestAPIv2Adapter, ZoneTransferRequestListAPIv2Adapter # noqa from designate.objects.adapters.api_v2.validation_error import ValidationErrorAPIv2Adapter, ValidationErrorListAPIv2Adapter # noqa from designate.objects.adapters.api_v2.zone_import import ZoneImportAPIv2Adapter, ZoneImportListAPIv2Adapter # noqa +from designate.objects.adapters.api_v2.zone_export import ZoneExportAPIv2Adapter, ZoneExportListAPIv2Adapter # noqa diff --git a/designate/objects/adapters/api_v2/zone_export.py b/designate/objects/adapters/api_v2/zone_export.py new file mode 100644 index 000000000..f46a44b14 --- /dev/null +++ b/designate/objects/adapters/api_v2/zone_export.py @@ -0,0 +1,81 @@ +# 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 ZoneExportAPIv2Adapter(base.APIv2Adapter): + + ADAPTER_OBJECT = objects.ZoneExport + + MODIFICATIONS = { + 'fields': { + "id": {}, + "status": {}, + "message": {}, + "location": {}, + "zone_id": { + 'rename': 'domain_id', + }, + "project_id": { + 'rename': 'tenant_id' + }, + "created_at": {}, + "updated_at": {}, + "version": {}, + }, + 'options': { + 'links': True, + 'resource_name': 'export', + 'collection_name': 'exports', + } + } + + @classmethod + def _get_path(cls, request): + return '/v2/zones/tasks/exports' + + @classmethod + def _render_object(cls, object, *args, **kwargs): + obj = super(ZoneExportAPIv2Adapter, cls)._render_object( + object, *args, **kwargs) + + if obj['location'] and obj['location'].startswith('designate://'): + # Get the base uri from the self link, which respects host headers + base_uri = obj['links']['self']. \ + split(cls._get_path(kwargs['request']))[0] + + obj['links']['export'] = \ + '%s/%s' % \ + (base_uri, obj['location'].split('://')[1]) + + return obj + + +class ZoneExportListAPIv2Adapter(base.APIv2Adapter): + + ADAPTER_OBJECT = objects.ZoneExportList + + MODIFICATIONS = { + 'options': { + 'links': True, + 'resource_name': 'export', + 'collection_name': 'exports', + } + } diff --git a/designate/objects/zone_export.py b/designate/objects/zone_export.py new file mode 100644 index 000000000..ed4867f5d --- /dev/null +++ b/designate/objects/zone_export.py @@ -0,0 +1,68 @@ +# 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 ZoneExport(base.DictObjectMixin, base.PersistentObjectMixin, + base.DesignateObject): + FIELDS = { + 'status': { + 'schema': { + "type": "string", + "enum": ["ACTIVE", "PENDING", "DELETED", "ERROR", "COMPLETE"], + }, + 'read_only': True + }, + 'task_type': { + 'schema': { + "type": "string", + "enum": ["EXPORT"], + }, + 'read_only': True + }, + 'tenant_id': { + 'schema': { + 'type': 'string', + }, + 'read_only': True + }, + 'location': { + 'schema': { + 'type': ['string', 'null'], + 'maxLength': 160 + }, + 'read_only': True + }, + 'message': { + 'schema': { + 'type': ['string', 'null'], + 'maxLength': 160 + }, + 'read_only': True + }, + 'domain_id': { + 'schema': { + "type": "string", + "format": "uuid" + }, + 'read_only': True + }, + } + + +class ZoneExportList(base.ListObjectMixin, base.DesignateObject, + base.PagedListObjectMixin): + LIST_ITEM_TYPE = ZoneExport diff --git a/designate/quota/__init__.py b/designate/quota/__init__.py index ee6e0408b..452eb3638 100644 --- a/designate/quota/__init__.py +++ b/designate/quota/__init__.py @@ -32,6 +32,8 @@ cfg.CONF.register_opts([ help='Number of records allowed per domain'), cfg.IntOpt('quota-recordset-records', default=20, help='Number of records allowed per recordset'), + cfg.IntOpt('quota-api-export-size', default=1000, + help='Number of recordsets allowed in a zone export'), ]) diff --git a/designate/quota/base.py b/designate/quota/base.py index 5347629fb..9a8b18a11 100644 --- a/designate/quota/base.py +++ b/designate/quota/base.py @@ -55,6 +55,7 @@ class Quota(DriverPlugin): 'domain_recordsets': cfg.CONF.quota_domain_recordsets, 'domain_records': cfg.CONF.quota_domain_records, 'recordset_records': cfg.CONF.quota_recordset_records, + 'api_export_size': cfg.CONF.quota_api_export_size, } def get_quota(self, context, tenant_id, resource): diff --git a/designate/storage/base.py b/designate/storage/base.py index 16471885a..c8427f0ff 100644 --- a/designate/storage/base.py +++ b/designate/storage/base.py @@ -691,6 +691,67 @@ class Storage(DriverPlugin): :param zone_import_id: Delete a Zone Import via ID """ + @abc.abstractmethod + def create_zone_export(self, context, zone_export): + """ + Create a Zone Export. + + :param context: RPC Context. + :param zone_export: Zone Export object with the values to be created. + """ + + @abc.abstractmethod + def get_zone_export(self, context, zone_export_id): + """ + Get a Zone Export via ID. + + :param context: RPC Context. + :param zone_export_id: Zone Export ID to get. + """ + + @abc.abstractmethod + def find_zone_exports(self, context, criterion=None, marker=None, + limit=None, sort_key=None, sort_dir=None): + """ + Find Zone Exports + + :param context: RPC Context. + :param criterion: Criteria to filter by. + :param marker: Resource ID from which after the requested page will + start after + :param limit: Integer limit of objects of the page size after the + marker + :param sort_key: Key from which to sort after. + :param sort_dir: Direction to sort after using sort_key. + """ + + @abc.abstractmethod + def find_zone_export(self, context, criterion): + """ + Find a single Zone Export. + + :param context: RPC Context. + :param criterion: Criteria to filter by. + """ + + @abc.abstractmethod + def update_zone_export(self, context, zone_export): + """ + Update a Zone Export + + :param context: RPC Context. + :param zone_export: Zone Export to update. + """ + + @abc.abstractmethod + def delete_zone_export(self, context, zone_export_id): + """ + Delete a Zone Export via ID. + + :param context: RPC Context. + :param zone_export_id: Delete a Zone Export via ID + """ + def ping(self, context): """Ping the Storage connection""" return { diff --git a/designate/storage/impl_sqlalchemy/__init__.py b/designate/storage/impl_sqlalchemy/__init__.py index 2f86538c8..3ae941fcf 100644 --- a/designate/storage/impl_sqlalchemy/__init__.py +++ b/designate/storage/impl_sqlalchemy/__init__.py @@ -1299,6 +1299,46 @@ class SQLAlchemyStorage(sqlalchemy_base.SQLAlchemy, storage_base.Storage): return self._delete(context, tables.zone_tasks, zone_import, exceptions.ZoneImportNotFound) + # Zone Export Methods + def _find_zone_exports(self, context, criterion, one=False, marker=None, + limit=None, sort_key=None, sort_dir=None): + if not criterion: + criterion = {} + criterion['task_type'] = 'EXPORT' + return self._find( + context, tables.zone_tasks, objects.ZoneExport, + objects.ZoneExportList, exceptions.ZoneExportNotFound, criterion, + one, marker, limit, sort_key, sort_dir) + + def create_zone_export(self, context, zone_export): + return self._create( + tables.zone_tasks, zone_export, exceptions.DuplicateZoneExport) + + def get_zone_export(self, context, zone_export_id): + return self._find_zone_exports(context, {'id': zone_export_id}, + one=True) + + def find_zone_exports(self, context, criterion=None, marker=None, + limit=None, sort_key=None, sort_dir=None): + return self._find_zone_exports(context, criterion, marker=marker, + limit=limit, sort_key=sort_key, + sort_dir=sort_dir) + + def find_zone_export(self, context, criterion): + return self._find_zone_exports(context, criterion, one=True) + + def update_zone_export(self, context, zone_export): + return self._update( + context, tables.zone_tasks, zone_export, + exceptions.DuplicateZoneExport, exceptions.ZoneExportNotFound) + + def delete_zone_export(self, context, zone_export_id): + # Fetch the existing zone_export, we'll need to return it. + zone_export = self._find_zone_exports(context, {'id': zone_export_id}, + one=True) + return self._delete(context, tables.zone_tasks, zone_export, + exceptions.ZoneExportNotFound) + # diagnostics def ping(self, context): start_time = time.time() diff --git a/designate/storage/impl_sqlalchemy/migrate_repo/versions/069_zone_tasks_location.py b/designate/storage/impl_sqlalchemy/migrate_repo/versions/069_zone_tasks_location.py new file mode 100644 index 000000000..19f4360f8 --- /dev/null +++ b/designate/storage/impl_sqlalchemy/migrate_repo/versions/069_zone_tasks_location.py @@ -0,0 +1,44 @@ +# 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 Enum, String +from sqlalchemy.schema import Column, MetaData, Table + +meta = MetaData() +TASK_TYPES = ['IMPORT', 'EXPORT'] + + +def upgrade(migrate_engine): + meta.bind = migrate_engine + + dialect = migrate_engine.url.get_dialect().name + + zone_tasks_table = Table('zone_tasks', meta, autoload=True) + + dialect = migrate_engine.url.get_dialect().name + + if dialect.startswith("postgresql"): + with migrate_engine.connect() as conn: + conn.execution_options(isolation_level="AUTOCOMMIT") + conn.execute( + "ALTER TYPE task_types ADD VALUE 'EXPORT' " + "AFTER 'IMPORT'") + conn.close() + + zone_tasks_table.c.task_type.alter(type=Enum(name='task_type', + *TASK_TYPES)) + + location = Column('location', String(160), nullable=True) + location.create(zone_tasks_table) diff --git a/designate/storage/impl_sqlalchemy/tables.py b/designate/storage/impl_sqlalchemy/tables.py index 48c1e549d..6edd29ea1 100644 --- a/designate/storage/impl_sqlalchemy/tables.py +++ b/designate/storage/impl_sqlalchemy/tables.py @@ -39,7 +39,7 @@ ACTIONS = ['CREATE', 'DELETE', 'UPDATE', 'NONE'] ZONE_ATTRIBUTE_KEYS = ('master',) ZONE_TYPES = ('PRIMARY', 'SECONDARY',) -ZONE_TASK_TYPES = ['IMPORT'] +ZONE_TASK_TYPES = ['IMPORT', 'EXPORT'] metadata = MetaData() @@ -334,6 +334,7 @@ zone_tasks = Table('zone_tasks', metadata, Column('status', Enum(name='resource_statuses', *TASK_STATUSES), nullable=False, server_default='ACTIVE', default='ACTIVE'), + Column('location', String(160), nullable=True), mysql_engine='INNODB', mysql_charset='utf8') diff --git a/designate/tests/test_quota/test_quota.py b/designate/tests/test_quota/test_quota.py index 5d83e2fb7..aac15f7bf 100644 --- a/designate/tests/test_quota/test_quota.py +++ b/designate/tests/test_quota/test_quota.py @@ -44,6 +44,7 @@ class QuotaTestCase(tests.TestCase): self.assertIsNotNone(quotas) self.assertEqual({ + 'api_export_size': cfg.CONF.quota_api_export_size, 'domains': cfg.CONF.quota_domains, 'domain_recordsets': cfg.CONF.quota_domain_recordsets, 'domain_records': cfg.CONF.quota_domain_records, diff --git a/designate/tests/unit/test_central/test_basic.py b/designate/tests/unit/test_central/test_basic.py index 95dcc6c39..1c4fdf0a0 100644 --- a/designate/tests/unit/test_central/test_basic.py +++ b/designate/tests/unit/test_central/test_basic.py @@ -1753,3 +1753,112 @@ class IsSubdomainTestCase(CentralBasic): r = self.service._is_subdomain(self.context, 'foo.a.b.example.com.', '1') self.assertEqual(r, 'example.com.') + + +class CentralZoneExportTests(CentralBasic): + def setUp(self): + super(CentralZoneExportTests, self).setUp() + + def storage_find_tld(c, d): + if d['name'] not in ('org',): + raise exceptions.TldNotFound + + self.service.storage.find_tld = storage_find_tld + + def test_create_zone_export(self): + self.context = Mock() + self.context.tenant = 't' + + self.service.storage.get_domain.return_value = RoObject( + name='example.com.', + id='123' + ) + + self.service.storage.create_zone_export = Mock( + return_value=RoObject( + domain_id='123', + task_type='EXPORT', + status='PENDING', + message=None, + tenant_id='t' + ) + ) + + self.service.zone_manager_api.start_zone_export = Mock() + + out = self.service.create_zone_export( + self.context, + '123' + ) + self.assertEqual(out.domain_id, '123') + self.assertEqual(out.status, 'PENDING') + self.assertEqual(out.task_type, 'EXPORT') + self.assertEqual(out.message, None) + self.assertEqual(out.tenant_id, 't') + + def test_get_zone_export(self): + self.context = Mock() + self.context.tenant = 't' + + self.service.storage.get_zone_export.return_value = RoObject( + domain_id='123', + task_type='EXPORT', + status='PENDING', + message=None, + tenant_id='t' + ) + + out = self.service.get_zone_export(self.context, '1') + + n, ctx, target = designate.central.service.policy.check.call_args[0] + + # Check arguments to policy + self.assertEqual(target['tenant_id'], 't') + + # Check output + self.assertEqual(out.domain_id, '123') + self.assertEqual(out.status, 'PENDING') + self.assertEqual(out.task_type, 'EXPORT') + self.assertEqual(out.message, None) + self.assertEqual(out.tenant_id, 't') + + def test_find_zone_exports(self): + self.context = Mock() + self.context.tenant = 't' + self.service.storage.find_zone_exports = Mock() + + self.service.find_zone_exports(self.context) + + assert self.service.storage.find_zone_exports.called + pcheck, ctx, target = \ + designate.central.service.policy.check.call_args[0] + self.assertEqual(pcheck, 'find_zone_exports') + + def test_delete_zone_export(self): + self.context = Mock() + self.context.tenant = 't' + + self.service.storage.delete_zone_export = Mock( + return_value=RoObject( + domain_id='123', + task_type='EXPORT', + status='PENDING', + message=None, + tenant_id='t' + ) + ) + + out = self.service.delete_zone_export(self.context, '1') + + assert self.service.storage.delete_zone_export.called + + self.assertEqual(out.domain_id, '123') + self.assertEqual(out.status, 'PENDING') + self.assertEqual(out.task_type, 'EXPORT') + self.assertEqual(out.message, None) + self.assertEqual(out.tenant_id, 't') + + assert designate.central.service.policy.check.called + pcheck, ctx, target = \ + designate.central.service.policy.check.call_args[0] + self.assertEqual(pcheck, 'delete_zone_export') diff --git a/designate/zone_manager/__init__.py b/designate/zone_manager/__init__.py index da117578c..c7f1981d1 100644 --- a/designate/zone_manager/__init__.py +++ b/designate/zone_manager/__init__.py @@ -27,7 +27,11 @@ OPTS = [ cfg.IntOpt('threads', default=1000, help='Number of Zone Manager greenthreads to spawn'), cfg.ListOpt('enabled_tasks', default=None, - help='Enabled tasks to run') + help='Enabled tasks to run'), + cfg.StrOpt('storage-driver', default='sqlalchemy', + help='The storage driver to use'), + cfg.BoolOpt('export-synchronous', default=True, + help='Whether to allow synchronous zone exports'), ] CONF.register_opts(OPTS, group='service:zone_manager') diff --git a/designate/zone_manager/rpcapi.py b/designate/zone_manager/rpcapi.py new file mode 100644 index 000000000..e5780bb7d --- /dev/null +++ b/designate/zone_manager/rpcapi.py @@ -0,0 +1,72 @@ +# 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_config import cfg +from oslo_log import log as logging +import oslo_messaging as messaging + +from designate.i18n import _LI +from designate import rpc + + +LOG = logging.getLogger(__name__) + +ZONE_MANAGER_API = None + + +class ZoneManagerAPI(object): + """ + Client side of the zone manager RPC API. + + API version history: + + 1.0 - Initial version + """ + RPC_API_VERSION = '1.0' + + def __init__(self, topic=None): + topic = topic if topic else cfg.CONF.zone_manager_topic + + target = messaging.Target(topic=topic, version=self.RPC_API_VERSION) + self.client = rpc.get_client(target, version_cap='1.0') + + @classmethod + def get_instance(cls): + """ + The rpc.get_client() which is called upon the API object initialization + will cause a assertion error if the designate.rpc.TRANSPORT isn't setup + by rpc.init() before. + + This fixes that by creating the rpcapi when demanded. + """ + global ZONE_MANAGER_API + if not ZONE_MANAGER_API: + ZONE_MANAGER_API = cls() + return ZONE_MANAGER_API + + # Zone Export + def start_zone_export(self, context, domain, export): + LOG.info(_LI("start_zone_export: " + "Calling zone_manager's start_zone_export.")) + + return self.client.cast(context, 'start_zone_export', domain=domain, + export=export) + + def render_zone(self, context, zone_id): + LOG.info(_LI("render_zone: " + "Calling zone_manager's render_zone.")) + + return self.client.call(context, 'render_zone', + zone_id=zone_id) diff --git a/designate/zone_manager/service.py b/designate/zone_manager/service.py index f93655813..e51385bd6 100644 --- a/designate/zone_manager/service.py +++ b/designate/zone_manager/service.py @@ -15,10 +15,16 @@ # under the License. from oslo_config import cfg from oslo_log import log as logging +import oslo_messaging as messaging from designate.i18n import _LI from designate import coordination +from designate import exceptions +from designate import quota from designate import service +from designate import storage +from designate import utils +from designate.central import rpcapi from designate.zone_manager import tasks @@ -28,11 +34,29 @@ CONF = cfg.CONF NS = 'designate.periodic_tasks' -class Service(coordination.CoordinationMixin, service.Service): +class Service(service.RPCService, coordination.CoordinationMixin, + service.Service): + RPC_API_VERSION = '1.0' + + target = messaging.Target(version=RPC_API_VERSION) + + def __init__(self, threads=None): + super(Service, self).__init__(threads=threads) + + storage_driver = cfg.CONF['service:zone_manager'].storage_driver + self.storage = storage.get_storage(storage_driver) + + # Get a quota manager instance + self.quota = quota.get_quota() + @property def service_name(self): return 'zone_manager' + @property + def central_api(self): + return rpcapi.CentralAPI.get_instance() + def start(self): super(Service, self).start() @@ -59,3 +83,67 @@ class Service(coordination.CoordinationMixin, service.Service): def _rebalance(self, my_partitions, members, event): LOG.info(_LI("Received rebalance event %s") % event) self.partition_range = my_partitions + + # Begin RPC Implementation + + # Zone Export + def start_zone_export(self, context, domain, export): + criterion = {'domain_id': domain.id} + count = self.storage.count_recordsets(context, criterion) + + export = self._determine_export_method(context, export, count) + + self.central_api.update_zone_export(context, export) + + def render_zone(self, context, zone_id): + return self._export_zone(context, zone_id) + + def _determine_export_method(self, context, export, size): + synchronous = CONF['service:zone_manager'].export_synchronous + + # NOTE(timsim): + # The logic here with swift will work like this: + # cfg.CONF.export_swift_enabled: + # An export will land in their swift container, even if it's + # small, but the link that comes back will be the synchronous + # link (unless export_syncronous is False, in which case it + # will behave like the next option) + # cfg.CONF.export_swift_preffered: + # The link that the user gets back will always be the swift + # container, and status of the export resource will depend + # on the Swift process. + # If the export is too large for synchronous, or synchronous is not + # enabled and swift is not enabled, it will fall through to ERROR + # swift = False + + if synchronous: + try: + self.quota.limit_check( + context, context.tenant, api_export_size=size) + except exceptions.OverQuota(): + LOG.debug('Zone Export too large to perform synchronously') + export['status'] = 'ERROR' + export['message'] = 'Zone is too large to export' + return export + + export['location'] = \ + "designate://v2/zones/tasks/exports/%(eid)s/export" % \ + {'eid': export['id']} + + export['status'] = 'COMPLETE' + else: + LOG.debug('No method found to export zone') + export['status'] = 'ERROR' + export['message'] = 'No suitable method for export' + + return export + + def _export_zone(self, context, zone_id): + domain = self.central_api.get_domain(context, zone_id) + + criterion = {'domain_id': zone_id} + recordsets = self.storage.find_recordsets_export(context, criterion) + + return utils.render_template('export-zone.jinja2', + domain=domain, + recordsets=recordsets) diff --git a/doc/source/rest.rst b/doc/source/rest.rst index beb8ed86b..44d27b4d8 100644 --- a/doc/source/rest.rst +++ b/doc/source/rest.rst @@ -99,5 +99,4 @@ 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 deleted file mode 100644 index 5b3829b40..000000000 --- a/doc/source/rest/admin/zones.rst +++ /dev/null @@ -1,59 +0,0 @@ -Zones -===== - -Overview --------- -The zones extension can be used to export zonesfiles from designate. - -*Note*: Zones is an extension and needs to be enabled before it can be used. -If Designate returns a 404 error, ensure that the following line has been -added to the designate.conf file:: - - enabled_extensions_admin = zones - -Once this line has been added, restart the designate-api service. - -Export Zone ------------ - -.. http:get:: /admin/zones/export/(uuid:id) - - **Example request:** - - .. sourcecode:: http - - GET /admin/zones/export/a86dba58-0043-4cc6-a1bb-69d5e86f3ca3 HTTP/1.1 - Host: 127.0.0.1:9001 - Accept: text/dns - - - **Example response:** - - .. sourcecode:: http - - HTTP/1.1 200 OK - Content-Type: text/dns - - $ORIGIN example.com. - $TTL 42 - - example.com. IN SOA ns.designate.com. nsadmin.example.com. ( - 1394213803 ; serial - 3600 ; refresh - 600 ; retry - 86400 ; expire - 3600 ; minimum - ) - - - example.com. IN NS ns.designate.com. - - - example.com. IN MX 10 mail.example.com. - ns.example.com. IN A 10.0.0.1 - mail.example.com. IN A 10.0.0.2 - - :statuscode 200: Success - :statuscode 406: Not Acceptable - - Notice how the SOA and NS records are replaced with the Designate server(s). diff --git a/doc/source/rest/v2/zones.rst b/doc/source/rest/v2/zones.rst index d6ad09026..4cc07d117 100644 --- a/doc/source/rest/v2/zones.rst +++ b/doc/source/rest/v2/zones.rst @@ -748,3 +748,232 @@ Delete Zone Import HTTP/1.1 204 No Content :statuscode 204: No Content + +Export Zone +----------- + +Create a Zone Export +^^^^^^^^^^^^^^^^^^^^ + +.. http:post:: /zones/(uuid:id)/tasks/export + + To export a zone in BIND9 zonefile format, a zone export resource must be + created. This is accomplished by initializing an export task. + + **Example request:** + + .. sourcecode:: http + + POST /v2/zones/074e805e-fe87-4cbb-b10b-21a06e215d41/tasks/export HTTP/1.1 + Host: 127.0.0.1:9001 + + **Example response:** + + .. sourcecode:: http + + HTTP/1.1 202 Accepted + Content-Type: application/json + + { + "status": "PENDING", + "zone_id": "074e805e-fe87-4cbb-b10b-21a06e215d41", + "links": { + "self": "http://127.0.0.1:9001/v2/zones/tasks/exports/8ec17fe1-d1f9-41b4-aa98-4eeb4c27b720" + }, + "created_at": "2015-08-27T20:57:03.000000", + "updated_at": null, + "version": 1, + "location": null, + "message": null, + "project_id": "1", + "id": "8ec17fe1-d1f9-41b4-aa98-4eeb4c27b720" + } + :statuscode 202: Accepted + +View a Zone Export Record +^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. http:get:: /zones/tasks/exports/(uuid:id) + + The status of a zone export can be viewed by querying the id + given when the request was created. + + **Example request:** + + .. sourcecode:: http + + GET /v2/zones/tasks/exports/a86dba58-0043-4cc6-a1bb-69d5e86f3ca3 HTTP/1.1 + Host: 127.0.0.1:9001 + Accept: application/json + + **Example response:** + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "status": "COMPLETE", + "zone_id": "6625198b-d67d-47dc-8d29-f90bd60f3ac4", + "links": { + "self": "http://127.0.0.1:9001/v2/zones/tasks/exports/8ec17fe1-d1f9-41b4-aa98-4eeb4c27b720", + "export": "http://127.0.0.1:9001/v2/zones/tasks/exports/8ec17fe1-d1f9-41b4-aa98-4eeb4c27b720/export" + }, + "created_at": "2015-08-27T20:57:03.000000", + "updated_at": "2015-08-27T20:57:03.000000", + "version": 2, + "location": "designate://v2/zones/tasks/exports/8ec17fe1-d1f9-41b4-aa98-4eeb4c27b720/export", + "message": null, + "project_id": "noauth-project", + "id": "8ec17fe1-d1f9-41b4-aa98-4eeb4c27b720" + } + + :statuscode 200: Success + :statuscode 401: Access Denied + :statuscode 404: Not Found + + Notice the status has been updated and there is now an 'export' in the 'links' field that points + to a link where the export (zonefile) can be accessed. + + +View the Exported Zone +^^^^^^^^^^^^^^^^^^^^^^ + +The link that is generated in the export field in an export resource can be followed to +a Designate resource, or an external resource. If the link is to a Designate endpoint, the +zonefile can be retrieved directly through the API by following that link. + +.. http:get:: /zones/tasks/exports/(uuid:id) + + **Example request:** + + .. sourcecode:: http + + GET /zones/tasks/exports/8ec17fe1-d1f9-41b4-aa98-4eeb4c27b720/export HTTP/1.1 + Host: 127.0.0.1:9001 + Accept: text/dns + + + **Example response:** + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: text/dns + + $ORIGIN example.com. + $TTL 42 + + example.com. IN SOA ns.designate.com. nsadmin.example.com. ( + 1394213803 ; serial + 3600 ; refresh + 600 ; retry + 86400 ; expire + 3600 ; minimum + ) + + + example.com. IN NS ns.designate.com. + + + example.com. IN MX 10 mail.example.com. + ns.example.com. IN A 10.0.0.1 + mail.example.com. IN A 10.0.0.2 + + :statuscode 200: Success + :statuscode 401: Access Denied + :statuscode 404: Not Found + + Notice how the SOA and NS records are replaced with the Designate server(s). + +List Zone Exports +^^^^^^^^^^^^^^^^^ + +.. http:get:: /zones/tasks/exports/ + + List all of the zone exports created by this project. + + **Example request:** + + .. sourcecode:: http + + GET /v2/zones/tasks/exports/ HTTP/1.1 + Host: 127.0.0.1:9001 + Accept: application/json + + **Example response:** + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "exports": [ + { + "status": "COMPLETE", + "zone_id": "30ea7692-7f9e-4195-889e-0ba11620b491", + "links": { + "self": "http://127.0.0.1:9001/v2/zones/tasks/exports/d2f36aa6-2da4-4b22-a2a9-9cdf19a2f248", + "export": "http://127.0.0.1:9001/v2/zones/30ea7692-7f9e-4195-889e-0ba11620b491/tasks/exports/d2f36aa6-2da4-4b22-a2a9-9cdf19a2f248/export" + }, + "created_at": "2015-08-24T19:46:50.000000", + "updated_at": "2015-08-24T19:46:50.000000", + "version": 2, + "location": "designate://v2/zones/30ea7692-7f9e-4195-889e-0ba11620b491/tasks/exports/d2f36aa6-2da4-4b22-a2a9-9cdf19a2f248/export", + "message": null, + "project_id": "noauth-project", + "id": "d2f36aa6-2da4-4b22-a2a9-9cdf19a2f248" + }, + { + "status": "COMPLETE", + "zone_id": "0503f9fd-3938-47a4-bbf3-df99b088abfc", + "links": { + "self": "http://127.0.0.1:9001/v2/zones/tasks/exports/3d7d07a5-2ce3-458e-b3dd-6a29906234d8", + "export": "http://127.0.0.1:9001/v2/zones/tasks/exports/3d7d07a5-2ce3-458e-b3dd-6a29906234d8/export" + }, + "created_at": "2015-08-25T15:16:10.000000", + "updated_at": "2015-08-25T15:16:10.000000", + "version": 2, + "location": "designate://v2/zones/tasks/exports/3d7d07a5-2ce3-458e-b3dd-6a29906234d8/export", + "message": null, + "project_id": "noauth-project", + "id": "3d7d07a5-2ce3-458e-b3dd-6a29906234d8" + }, + ], + "links": { + "self": "http://127.0.0.1:9001/v2/zones/tasks/exports" + } + } + + :statuscode 200: Success + :statuscode 401: Access Denied + :statuscode 404: Not Found + +Delete Zone Export +^^^^^^^^^^^^^^^^^^ + +.. http:delete:: /zones/tasks/exports/(uuid:id) + + Deletes a zone export with the specified ID. This does not affect the zone + that was exported, it simply removes the record of the export. If the link + to view the export was pointing to a Designate API endpoint, the endpoint + will no longer be available. + + **Example Request:** + + .. sourcecode:: http + + DELETE /v2/zones/tasks/exports/a86dba58-0043-4cc6-a1bb-69d5e86f3ca3 HTTP/1.1 + Host: 127.0.0.1:9001 + Accept: application/json + Content-Type: application/json + + **Example Response:** + + .. sourcecode:: http + + HTTP/1.1 204 No Content + + :statuscode 204: No Content \ No newline at end of file diff --git a/etc/designate/designate.conf.sample b/etc/designate/designate.conf.sample index 8ace65ea4..de804cee3 100644 --- a/etc/designate/designate.conf.sample +++ b/etc/designate/designate.conf.sample @@ -216,6 +216,9 @@ debug = False # Can be one or more of: periodic_exists #enabled_tasks = None +# Whether to allow synchronous zone exports +#export_synchronous = True + #----------------------- # Pool Manager Service #----------------------- diff --git a/etc/designate/policy.json b/etc/designate/policy.json index 213891221..03daf8a32 100644 --- a/etc/designate/policy.json +++ b/etc/designate/policy.json @@ -108,11 +108,16 @@ "update_zone_transfer_accept": "rule:admin", "delete_zone_transfer_accept": "rule:admin", - "zone_export": "rule:admin_or_owner", - "create_zone_import": "rule:admin_or_owner", "find_zone_imports": "rule:admin_or_owner", "get_zone_import": "rule:admin_or_owner", "update_zone_import": "rule:admin_or_owner", - "delete_zone_import": "rule:admin_or_owner" + "delete_zone_import": "rule:admin_or_owner", + + "zone_export": "rule:admin_or_owner", + "create_zone_export": "rule:admin_or_owner", + "find_zone_exports": "rule:admin_or_owner", + "get_zone_export": "rule:admin_or_owner", + "update_zone_export": "rule:admin_or_owner", + "delete_zone_export": "rule:admin_or_owner" } diff --git a/functionaltests/api/v2/clients/zone_export_client.py b/functionaltests/api/v2/clients/zone_export_client.py new file mode 100644 index 000000000..427d59c73 --- /dev/null +++ b/functionaltests/api/v2/clients/zone_export_client.py @@ -0,0 +1,71 @@ +""" +Copyright 2015 Rackspace + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +from functionaltests.api.v2.models.zone_export_model import ZoneExportModel +from functionaltests.api.v2.models.zone_export_model import ZoneExportListModel +from functionaltests.common.client import ClientMixin +from functionaltests.common import utils + + +class ZoneExportClient(ClientMixin): + + @classmethod + def zone_exports_uri(cls, filters=None): + url = "/v2/zones/tasks/exports" + if filters: + url = cls.add_filters(url, filters) + return url + + @classmethod + def zone_export_uri(cls, id): + return "{0}/{1}".format(cls.zone_exports_uri(), id) + + def list_zone_exports(self, filters=None, **kwargs): + resp, body = self.client.get( + self.zone_exports_uri(filters), **kwargs) + return self.deserialize(resp, body, ZoneExportListModel) + + def get_zone_export(self, id, **kwargs): + resp, body = self.client.get(self.zone_export_uri(id)) + return self.deserialize(resp, body, ZoneExportModel) + + def get_exported_zone(self, id, **kwargs): + uri = "/v2/zones/tasks/exports/{0}".format(id) + resp, body = self.client.get(uri) + return resp, body + + def post_zone_export(self, zone_id, **kwargs): + uri = "/v2/zones/{0}/tasks/export".format(zone_id) + + resp, body = self.client.post(uri, body='', **kwargs) + return self.deserialize(resp, body, ZoneExportModel) + + def delete_zone_export(self, id, **kwargs): + resp, body = self.client.delete(self.zone_export_uri(id), **kwargs) + return resp, body + + def wait_for_zone_export(self, zone_export_id): + utils.wait_for_condition( + lambda: self.is_zone_export_active(zone_export_id)) + + def is_zone_export_active(self, zone_export_id): + resp, model = self.get_zone_export(zone_export_id) + # don't have assertEqual but still want to fail fast + assert resp.status == 200 + if model.status == 'COMPLETE': + return True + elif model.status == 'ERROR': + raise Exception("Saw ERROR status") + return False diff --git a/functionaltests/api/v2/models/zone_export_model.py b/functionaltests/api/v2/models/zone_export_model.py new file mode 100644 index 000000000..4cb4a39f6 --- /dev/null +++ b/functionaltests/api/v2/models/zone_export_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 ZoneExportModel(BaseModel): + pass + + +class ZoneExportListModel(CollectionModel): + COLLECTION_NAME = 'exports' + MODEL_TYPE = ZoneExportModel diff --git a/functionaltests/api/v2/test_zone.py b/functionaltests/api/v2/test_zone.py index 8295ace12..2a10cad4e 100644 --- a/functionaltests/api/v2/test_zone.py +++ b/functionaltests/api/v2/test_zone.py @@ -22,6 +22,7 @@ from functionaltests.common import datagen from functionaltests.api.v2.base import DesignateV2Test from functionaltests.api.v2.clients.zone_client import ZoneClient from functionaltests.api.v2.clients.zone_import_client import ZoneImportClient +from functionaltests.api.v2.clients.zone_export_client import ZoneExportClient class ZoneTest(DesignateV2Test): @@ -140,3 +141,35 @@ class ZoneImportTest(DesignateV2Test): import_client.delete_zone_import(import_id) self.assertRaises(NotFound, lambda: import_client.get_zone_import(model.id)) + + +class ZoneExportTest(DesignateV2Test): + + def setUp(self): + super(ZoneExportTest, self).setUp() + + def test_import_domain(self): + user = 'default' + resp, zone = ZoneClient.as_user(user).post_zone( + datagen.random_zone_data()) + ZoneClient.as_user(user).wait_for_zone(zone.id) + + export_client = ZoneExportClient.as_user(user) + + resp, model = export_client.post_zone_export(zone.id) + + export_id = model.id + self.assertEqual(resp.status, 202) + self.assertEqual(model.status, 'PENDING') + export_client.wait_for_zone_export(export_id) + + resp, model = export_client.get_zone_export(export_id) + self.assertEqual(resp.status, 200) + self.assertEqual(model.status, 'COMPLETE') + + resp, body = export_client.get_exported_zone(export_id) + self.assertEqual(resp.status, 200) + + export_client.delete_zone_export(export_id) + self.assertRaises(NotFound, + lambda: export_client.get_zone_export(model.id))