diff --git a/designate/api/v2/controllers/zones/__init__.py b/designate/api/v2/controllers/zones/__init__.py index 438e1280..77330787 100644 --- a/designate/api/v2/controllers/zones/__init__.py +++ b/designate/api/v2/controllers/zones/__init__.py @@ -248,14 +248,8 @@ class ZonesController(rest.RestController): response = pecan.response context = request.environ['context'] - # TODO(kiall): Validate we have a sane UUID for zone_id - - zone = self.central_api.delete_domain(context, zone_id) - - if zone['status'] == 'DELETING': - response.status_int = 202 - else: - response.status_int = 204 + self.central_api.delete_domain(context, zone_id) + response.status_int = 202 # NOTE: This is a hack and a half.. But Pecan needs it. return '' diff --git a/designate/api/v2/controllers/zones/tasks/__init__.py b/designate/api/v2/controllers/zones/tasks/__init__.py index a6c5910d..5db8b0a3 100644 --- a/designate/api/v2/controllers/zones/tasks/__init__.py +++ b/designate/api/v2/controllers/zones/tasks/__init__.py @@ -20,6 +20,7 @@ from designate.api.v2.controllers.zones.tasks.transfer_requests \ import TransferRequestsController as TRC from designate.api.v2.controllers.zones.tasks.transfer_accepts \ import TransferAcceptsController as TRA +from designate.api.v2.controllers.zones.tasks import abandon LOG = logging.getLogger(__name__) @@ -28,3 +29,4 @@ class TasksController(rest.RestController): transfer_accepts = TRA() transfer_requests = TRC() + abandon = abandon.AbandonController() diff --git a/designate/api/v2/controllers/zones/tasks/abandon.py b/designate/api/v2/controllers/zones/tasks/abandon.py new file mode 100644 index 00000000..3a33ac59 --- /dev/null +++ b/designate/api/v2/controllers/zones/tasks/abandon.py @@ -0,0 +1,40 @@ +# Copyright (c) 2015 Rackspace Hosting +# All Rights Reserved. +# +# 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 designate import utils +from designate.api.v2.controllers import rest + + +class AbandonController(rest.RestController): + + @pecan.expose(template='json:', content_type='application/json') + @utils.validate_uuid('zone_id') + def post_all(self, zone_id): + """Abandon a zone""" + request = pecan.request + response = pecan.response + context = request.environ['context'] + context.abandon = 'True' + + # abandon the zone + zone = self.central_api.delete_domain(context, zone_id) + if zone.deleted_at: + response.status_int = 204 + else: + response.status_int = 500 + + # NOTE: This is a hack and a half.. But Pecan needs it. + return '' diff --git a/designate/api/v2/views/zones/__init__.py b/designate/api/v2/views/zones/__init__.py index 27b7f24a..4ee96909 100644 --- a/designate/api/v2/views/zones/__init__.py +++ b/designate/api/v2/views/zones/__init__.py @@ -39,6 +39,7 @@ class ZonesView(base_view.BaseView): "ttl": zone['ttl'], "serial": zone['serial'], "status": zone['status'], + "action": zone['action'], "version": zone['version'], "created_at": zone['created_at'], "updated_at": zone['updated_at'], diff --git a/designate/central/service.py b/designate/central/service.py index 3b7c6d69..01e99f05 100644 --- a/designate/central/service.py +++ b/designate/central/service.py @@ -30,6 +30,7 @@ from oslo_concurrency import lockutils from designate.i18n import _LI from designate.i18n import _LC +from designate.i18n import _LW from designate import context as dcontext from designate import exceptions from designate import network_api @@ -914,7 +915,10 @@ class Service(service.RPCService): 'tenant_id': domain.tenant_id } - policy.check('delete_domain', context, target) + if hasattr(context, 'abandon') and context.abandon: + policy.check('abandon_domain', context, target) + else: + policy.check('delete_domain', context, target) # Prevent deletion of a zone which has child zones criterion = {'parent_domain_id': domain_id} @@ -923,9 +927,12 @@ class Service(service.RPCService): raise exceptions.DomainHasSubdomain('Please delete any subdomains ' 'before deleting this domain') - domain = self._delete_domain_in_storage(context, domain) - - self.pool_manager_api.delete_domain(context, domain) + if hasattr(context, 'abandon') and context.abandon: + LOG.info(_LW("Abandoning zone '%(zone)s'") % {'zone': domain.name}) + domain = self.storage.delete_domain(context, domain.id) + else: + domain = self._delete_domain_in_storage(context, domain) + self.pool_manager_api.delete_domain(context, domain) return domain @@ -1997,6 +2004,8 @@ class Service(service.RPCService): # used to indicate the domain has been deleted and not the deleted # column. The deleted column is needed for unique constraints. if deleted: + # TODO(vinod): Pass a domain to delete_domain rather than id so + # that the action, status and serial are updated correctly. self.storage.delete_domain(context, domain.id) def _update_record_status(self, context, domain_id, status, serial): diff --git a/designate/context.py b/designate/context.py index 475b187e..e00e81e2 100644 --- a/designate/context.py +++ b/designate/context.py @@ -29,12 +29,13 @@ LOG = logging.getLogger(__name__) class DesignateContext(context.RequestContext): _all_tenants = False + _abandon = None def __init__(self, auth_token=None, user=None, tenant=None, domain=None, user_domain=None, project_domain=None, is_admin=False, read_only=False, show_deleted=False, request_id=None, resource_uuid=None, roles=None, service_catalog=None, - all_tenants=False, user_identity=None): + all_tenants=False, user_identity=None, abandon=None): # NOTE: user_identity may be passed in, but will be silently dropped as # it is a generated field based on several others. @@ -56,6 +57,7 @@ class DesignateContext(context.RequestContext): self.service_catalog = service_catalog self.all_tenants = all_tenants + self.abandon = abandon if not hasattr(local.store, 'context'): self.update_store() @@ -75,6 +77,7 @@ class DesignateContext(context.RequestContext): 'roles': self.roles, 'service_catalog': self.service_catalog, 'all_tenants': self.all_tenants, + 'abandon': self.abandon, }) return copy.deepcopy(d) @@ -128,3 +131,13 @@ class DesignateContext(context.RequestContext): if value: policy.check('all_tenants', self) self._all_tenants = value + + @property + def abandon(self): + return self._abandon + + @abandon.setter + def abandon(self, value): + if value: + policy.check('abandon_domain', self) + self._abandon = value diff --git a/designate/resources/schemas/v2/zone.json b/designate/resources/schemas/v2/zone.json index 22d17916..10e5f7ea 100644 --- a/designate/resources/schemas/v2/zone.json +++ b/designate/resources/schemas/v2/zone.json @@ -61,7 +61,13 @@ "status": { "type": "string", "description": "Zone Status", - "enum": ["ACTIVE", "PENDING", "DELETING", "ERROR"], + "enum": ["ACTIVE", "PENDING", "ERROR"], + "readOnly": true + }, + "action": { + "type": "string", + "description": "Zone Action", + "enum": ["CREATE", "DELETE", "UPDATE", "NONE"], "readOnly": true }, "serial": { diff --git a/designate/sqlalchemy/base.py b/designate/sqlalchemy/base.py index ef5750e4..db0f3db7 100644 --- a/designate/sqlalchemy/base.py +++ b/designate/sqlalchemy/base.py @@ -297,6 +297,11 @@ class SQLAlchemy(object): obj.deleted = obj.id.replace('-', '') obj.deleted_at = timeutils.utcnow() + # TODO(vinod): Change the action to be null + # update the action and status before deleting the object + obj.action = 'NONE' + obj.status = 'DELETED' + # NOTE(kiall): It should be impossible for a duplicate exception to # be raised in this call, therefore, it is OK to pass # in "None" as the exc_dup param. diff --git a/designate/tests/test_api/test_v2/test_recordsets.py b/designate/tests/test_api/test_v2/test_recordsets.py index 84c93298..9b0b0f3e 100644 --- a/designate/tests/test_api/test_v2/test_recordsets.py +++ b/designate/tests/test_api/test_v2/test_recordsets.py @@ -252,7 +252,7 @@ class ApiV2RecordSetsTest(ApiV2TestCase): self.assertEqual(200, response.status_int) # now delete the domain and get the recordsets - self.client.delete('/zones/%s' % zone['id'], status=204) + self.client.delete('/zones/%s' % zone['id'], status=202) # Simulate the domain having been deleted on the backend domain_serial = self.central_service.get_domain( diff --git a/designate/tests/test_api/test_v2/test_zones.py b/designate/tests/test_api/test_v2/test_zones.py index 9d166b12..572e3ce3 100644 --- a/designate/tests/test_api/test_v2/test_zones.py +++ b/designate/tests/test_api/test_v2/test_zones.py @@ -334,7 +334,7 @@ class ApiV2ZonesTest(ApiV2TestCase): def test_delete_zone(self): zone = self.create_domain() - self.client.delete('/zones/%s' % zone['id'], status=204) + self.client.delete('/zones/%s' % zone['id'], status=202) def test_delete_zone_invalid_id(self): self._assert_invalid_uuid(self.client.delete, '/zones/%s') @@ -354,6 +354,18 @@ class ApiV2ZonesTest(ApiV2TestCase): self._assert_exception('domain_not_found', 404, self.client.delete, url) + def test_abandon_zone(self): + zone = self.create_domain() + url = '/zones/%s/tasks/abandon' % zone.id + + # Ensure that we get permission denied + self._assert_exception('forbidden', 403, self.client.post_json, url) + + # Ensure that abandon zone succeeds with the right policy + self.policy({'abandon_domain': '@'}) + response = self.client.post_json(url) + self.assertEqual(204, response.status_int) + # Zone import/export def test_missing_origin(self): fixture = self.get_zonefile_fixture(variant='noorigin') diff --git a/doc/source/rest/v2/zones.rst b/doc/source/rest/v2/zones.rst index 92eccc3d..88f9e1d9 100644 --- a/doc/source/rest/v2/zones.rst +++ b/doc/source/rest/v2/zones.rst @@ -261,7 +261,9 @@ Delete Zone .. http:delete:: zones/(uuid:id) - Deletes a zone with the specified zone ID. + Deletes a zone with the specified zone ID. Deleting a zone is asynchronous. + Once pool manager has deleted the zone from all the pool targets, the zone + is deleted from storage. **Example Request:** @@ -276,9 +278,9 @@ Delete Zone .. sourcecode:: http - HTTP/1.1 204 No Content + HTTP/1.1 202 Accepted - :statuscode 204: No content + :statuscode 202: Accepted Import Zone ----------- @@ -380,6 +382,33 @@ Export Zone Notice how the SOA and NS records are replaced with the Designate server(s). +Abandon Zone +------------ + +.. http:post:: /zones/(uuid:id)/tasks/abandon + + When a zone is abandoned it removes the zone from Designate's storage. + There is no operation done on the pool targets. This is intended to be used + in the cases where Designate's storage is incorrect for whatever reason. By + default this is restricted by policy (abandon_domain) to admins. + + **Example Request:** + + .. sourcecode:: http + + POST /v2/zones/a86dba58-0043-4cc6-a1bb-69d5e86f3ca3/tasks/abandon 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 + Transfer Zone ------------- diff --git a/etc/designate/policy.json b/etc/designate/policy.json index 20c4fe05..9d963796 100644 --- a/etc/designate/policy.json +++ b/etc/designate/policy.json @@ -42,6 +42,7 @@ "find_domain": "rule:admin_or_owner", "update_domain": "rule:admin_or_owner", "delete_domain": "rule:admin_or_owner", + "abandon_domain": "rule:admin", "count_domains": "rule:admin_or_owner", "touch_domain": "rule:admin_or_owner",