Merge "Re-enable zone export list filter test"
diff --git a/.zuul.yaml b/.zuul.yaml
index 9cc66cf..02cd904 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -4,6 +4,7 @@
       - check-requirements
       - publish-openstack-docs-pti
       - tempest-plugin-jobs
+      - release-notes-jobs-python3
     check:
       jobs:
         - neutron-tempest-plugin-designate-scenario
diff --git a/designate_tempest_plugin/clients.py b/designate_tempest_plugin/clients.py
index 6fbfc05..deafecb 100644
--- a/designate_tempest_plugin/clients.py
+++ b/designate_tempest_plugin/clients.py
@@ -51,6 +51,7 @@
     import SevriceClient
 from designate_tempest_plugin.services.dns.v2.json.designate_limit_client \
     import DesignateLimitClient
+from designate_tempest_plugin.services.dns.v2.json.ptr_client import PtrClient
 
 CONF = config.CONF
 
@@ -99,6 +100,7 @@
         self.tsigkey_client = TsigkeyClient(**params)
         self.service_client = SevriceClient(**params)
         self.designate_limit_client = DesignateLimitClient(**params)
+        self.ptr_client = PtrClient(**params)
 
         self.query_client = QueryClient(
             nameservers=CONF.dns.nameservers,
diff --git a/designate_tempest_plugin/services/dns/json/base.py b/designate_tempest_plugin/services/dns/json/base.py
index 1f12642..cbfb34f 100644
--- a/designate_tempest_plugin/services/dns/json/base.py
+++ b/designate_tempest_plugin/services/dns/json/base.py
@@ -87,17 +87,25 @@
             expected_code=expected_code, read_code=int(read_code),
         )
 
-    def get_uri(self, resource_name, uuid=None, params=None):
+    def get_uri(self, resource_name, uuid=None, params=None,
+                uuid_prefix_char=None):
         """Get URI for a specific resource or object.
         :param resource_name: The name of the REST resource, e.g., 'zones'.
         :param uuid: The unique identifier of an object in UUID format.
         :param params: A Python dict that represents the query paramaters to
                        include in the request URI.
+        :param uuid_prefix_char: applies to override hardcoded ('/')
+                prefix UUID character. This parameter enables to set required
+                by API character, for example ":" instead of "/".
         :returns: Relative URI for the resource or object.
         """
         uri_pattern = '{pref}/{res}{uuid}{params}'
 
-        uuid = '/%s' % uuid if uuid else ''
+        if uuid_prefix_char:
+            uuid = uuid_prefix_char + '%s' % uuid if uuid else ''
+        else:
+            uuid = '/%s' % uuid if uuid else ''
+
         params = '?%s' % urllib.urlencode(params) if params else ''
 
         return uri_pattern.format(pref=self.uri_prefix,
@@ -141,7 +149,7 @@
         return resp, self.deserialize(resp, body)
 
     def _show_request(self, resource, uuid, headers=None, params=None,
-                      extra_headers=False):
+                      extra_headers=False, uuid_prefix_char=None):
         """Gets a specific object of the specified type.
         :param resource: The name of the REST resource, e.g., 'zones'.
         :param uuid: Unique identifier of the object in UUID format.
@@ -152,9 +160,13 @@
                                      method are to be used but additional
                                      headers are needed in the request
                                      pass them in as a dict.
+        :param uuid_prefix_char: applies to override hardcoded ('/')
+                prefix UUID character. This parameter enables to set required
+                by API character, for example ":" instead of "/".
         :returns: Serialized object as a dictionary.
         """
-        uri = self.get_uri(resource, uuid=uuid, params=params)
+        uri = self.get_uri(resource, uuid=uuid, params=params,
+                           uuid_prefix_char=uuid_prefix_char)
 
         resp, body = self.get(
             uri, headers=headers, extra_headers=extra_headers)
@@ -199,7 +211,7 @@
         return resp, self.deserialize(resp, body)
 
     def _update_request(self, resource, uuid, data, params=None, headers=None,
-                        extra_headers=False):
+                        extra_headers=False, uuid_prefix_char=None):
         """Updates the specified object using PATCH request.
         :param resource: The name of the REST resource, e.g., 'zones'
         :param uuid: Unique identifier of the object in UUID format.
@@ -214,13 +226,18 @@
                                      method are to be used but additional
                                      headers are needed in the request
                                      pass them in as a dict.
+        :param uuid_prefix_char: applies to override hardcoded ('/')
+                prefix UUID character. This parameter enables to set required
+                by API character, for example ":" instead of "/".
         :returns: Serialized object as a dictionary.
         """
         body = self.serialize(data)
-        uri = self.get_uri(resource, uuid=uuid, params=params)
+        uri = self.get_uri(
+            resource, uuid=uuid, params=params,
+            uuid_prefix_char=uuid_prefix_char)
 
         resp, body = self.patch(uri, body=body,
-                                headers=headers, extra_headers=True)
+                                headers=headers, extra_headers=extra_headers)
 
         self.expected_success(self.UPDATE_STATUS_CODES, resp.status)
 
diff --git a/designate_tempest_plugin/services/dns/v2/json/ptr_client.py b/designate_tempest_plugin/services/dns/v2/json/ptr_client.py
new file mode 100644
index 0000000..1bd59b3
--- /dev/null
+++ b/designate_tempest_plugin/services/dns/v2/json/ptr_client.py
@@ -0,0 +1,85 @@
+# Copyright 2021 Red Hat.
+#
+# 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 tempest.lib.common.utils import data_utils
+
+from designate_tempest_plugin import data_utils as dns_data_utils
+from designate_tempest_plugin.services.dns.v2.json import base
+from tempest import config
+
+CONF = config.CONF
+
+
+class PtrClient(base.DnsClientV2Base):
+
+    @base.handle_errors
+    def set_ptr_record(self, floatingip_id, ptr_name=None,
+                       ttl=None, description=None, headers=None):
+        """Set a PTR record for the given FloatingIP
+
+        :param floatingip_id: valid UUID of floating IP to be used.
+        :param ptr_name PTR record name or random if not provided.
+        :param ttl TTL or random valid value if not provided.
+        :param description Description or random if not provided.
+        :param headers (dict): The headers to use for the request.
+        :return: created PTR dictionary.
+        """
+        ptr = {
+            'ptrdname': ptr_name or dns_data_utils.rand_zone_name(),
+            'ttl': ttl or dns_data_utils.rand_ttl(),
+            'description': description or data_utils.rand_name(
+                'test-ptr')}
+
+        return self._update_request(
+            resource='reverse/floatingips/{}'.format(CONF.identity.region),
+            uuid=floatingip_id, data=ptr, headers=headers,
+            uuid_prefix_char=':')[1]
+
+    @base.handle_errors
+    def show_ptr_record(self, floatingip_id, headers=None):
+        """Show PTR record for the given FloatingIP
+
+        :param floatingip_id: valid UUID of floating IP to show.
+        :param headers (dict): The headers to use for the request.
+        :return: Shown PTR dictionary.
+        """
+        return self._show_request(
+            resource='reverse/floatingips/{}'.format(CONF.identity.region),
+            uuid=floatingip_id, headers=headers, uuid_prefix_char=':')[1]
+
+    @base.handle_errors
+    def list_ptr_records(self, headers=None):
+        """List PTR records for the given FloatingIP
+
+        :param headers (dict): The headers to use for the request.
+        :return: List of PTR records.
+        """
+        return self._list_request(
+            'reverse/floatingips', headers=headers)[1]['floatingips']
+
+    @base.handle_errors
+    def unset_ptr_record(self, floatingip_id, headers=None):
+        """Unset the PTR record for a given FloatingIP
+
+        :param floatingip_id: valid UUID of floating IP to unset.
+        :param headers (dict): The headers to use for the request.
+        :return: Tuple (Response, Body)
+        """
+        data = {"ptrdname": None}
+        resp, body = self._update_request(
+            resource='reverse/floatingips/{}'.format(CONF.identity.region),
+            uuid=floatingip_id, data=data, headers=headers,
+            uuid_prefix_char=':')
+        # Unset PTR should Return a HTTP 202
+        self.expected_success(202, resp.status)
+        return resp, body
diff --git a/designate_tempest_plugin/services/dns/v2/json/transfer_accepts_client.py b/designate_tempest_plugin/services/dns/v2/json/transfer_accepts_client.py
index a0612e9..e09f775 100644
--- a/designate_tempest_plugin/services/dns/v2/json/transfer_accepts_client.py
+++ b/designate_tempest_plugin/services/dns/v2/json/transfer_accepts_client.py
@@ -19,18 +19,20 @@
 
     @base.handle_errors
     def create_transfer_accept(self, transfer_accept_data,
-                               params=None):
+                               params=None, headers=None):
         """Create a zone transfer_accept.
         :param transfer_accept_data: A python dictionary representing
                                 data for the zone transfer accept.
         :param params: A Python dict that represents the query paramaters to
                        include in the accept URI.
+        :param headers (dict): The headers to use for the request.
         :return: Serialized accepted zone transfer as a dictionary.
         """
 
         transfer_accept_uri = 'zones/tasks/transfer_accepts'
         resp, body = self._create_request(
-            transfer_accept_uri, transfer_accept_data, params=params)
+            transfer_accept_uri, transfer_accept_data,
+            params=params, headers=headers)
 
         # Create Transfer accept should Return a HTTP 201
         self.expected_success(201, resp.status)
@@ -38,15 +40,17 @@
         return resp, body
 
     @base.handle_errors
-    def show_transfer_accept(self, uuid, params=None):
+    def show_transfer_accept(self, uuid, params=None, headers=None):
         """Gets a specific accepted zone transfer..
         :param uuid: Unique identifier of the transfer_accept.
         :param params: A Python dict that represents the query paramaters to
                        include in the accept URI.
+        :param headers (dict): The headers to use for the request.
         :return: Serialized accepted zone transfer as a dictionary.
         """
         return self._show_request(
-            'zones/tasks/transfer_accepts', uuid, params=params)
+            'zones/tasks/transfer_accepts', uuid,
+            params=params, headers=headers)
 
     @base.handle_errors
     def list_transfer_accept(self, params=None, headers=None):
diff --git a/designate_tempest_plugin/services/dns/v2/json/zones_client.py b/designate_tempest_plugin/services/dns/v2/json/zones_client.py
index 04a628b..4becb58 100644
--- a/designate_tempest_plugin/services/dns/v2/json/zones_client.py
+++ b/designate_tempest_plugin/services/dns/v2/json/zones_client.py
@@ -121,14 +121,16 @@
         return self._list_request('zones', params=params, headers=headers)
 
     @base.handle_errors
-    def delete_zone(self, uuid, params=None):
+    def delete_zone(self, uuid, params=None, headers=None):
         """Deletes a zone having the specified UUID.
         :param uuid: The unique identifier of the zone.
         :param params: A Python dict that represents the query paramaters to
                        include in the request URI.
+        :param headers (dict): The headers to use for the request.
         :return: A tuple with the server response and the response body.
         """
-        resp, body = self._delete_request('zones', uuid, params=params)
+        resp, body = self._delete_request(
+            'zones', uuid, params=params, headers=headers)
 
         # Delete Zone should Return a HTTP 202
         self.expected_success(202, resp.status)
diff --git a/designate_tempest_plugin/tests/api/v2/test_pool.py b/designate_tempest_plugin/tests/api/v2/test_pool.py
index d127006..e2516af 100644
--- a/designate_tempest_plugin/tests/api/v2/test_pool.py
+++ b/designate_tempest_plugin/tests/api/v2/test_pool.py
@@ -208,3 +208,79 @@
         self.assertEqual("invalid_uuid", resp_body['type'])
         self.assertEqual("Invalid UUID pool_id: foo",
                          resp_body['message'])
+
+
+class TestPoolAdminNegative(BasePoolTest):
+
+    credentials = ["admin"]
+
+    @classmethod
+    def setup_credentials(cls):
+        # Do not create network resources for these test.
+        cls.set_network_resources()
+        super(TestPoolAdminNegative, cls).setup_credentials()
+
+    @classmethod
+    def setup_clients(cls):
+        super(TestPoolAdminNegative, cls).setup_clients()
+        cls.admin_client = cls.os_admin.pool_client
+
+    @decorators.idempotent_id('0a8cdc1e-ac02-11eb-ae06-74e5f9e2a801')
+    def test_create_pool_invalid_name(self):
+        LOG.info('Create a pool using a huge size string for name)')
+        with self.assertRaisesDns(lib_exc.BadRequest, 'invalid_object', 400):
+            self.admin_client.create_pool(
+                pool_name=data_utils.rand_name(name="Huge_size_name") * 10000)
+
+    @decorators.idempotent_id('9a787d0e-ac04-11eb-ae06-74e5f9e2a801')
+    def test_create_pool_invalid_hostname_in_ns_records(self):
+        LOG.info('Create a pool using invalid hostname in ns_records')
+        with self.assertRaisesDns(lib_exc.BadRequest, 'invalid_object', 400):
+            self.admin_client.create_pool(
+                ns_records=[{"hostname": "ns1_example_org_", "priority": 1}])
+
+    @decorators.idempotent_id('9a787d0e-ac04-11eb-ae06-74e5f9e2a801')
+    def test_create_pool_invalid_priority_in_ns_records(self):
+        LOG.info('Create a pool using invalid priority in ns_records')
+        with self.assertRaisesDns(lib_exc.BadRequest, 'invalid_object', 400):
+            self.admin_client.create_pool(
+                ns_records=[{"hostname": "ns1.example.org.", "priority": -1}])
+
+    @decorators.idempotent_id('cc378e4c-ac05-11eb-ae06-74e5f9e2a801')
+    # Note: Update pool API is deprecated for removal.
+    def test_update_pool_with_invalid_name(self):
+        LOG.info('Create a pool')
+        pool = self.admin_client.create_pool()[1]
+        self.addCleanup(self.admin_client.delete_pool, pool['id'])
+
+        LOG.info('Update the pool using a name that is too long')
+        with self.assertRaisesDns(lib_exc.BadRequest, 'invalid_object', 400):
+            self.admin_client.update_pool(
+                pool['id'],
+                pool_name=data_utils.rand_name(name="Huge_size_name") * 10000)
+
+    @decorators.idempotent_id('2e496596-ac07-11eb-ae06-74e5f9e2a801')
+    def test_update_pool_with_invalid_hostname_in_ns_records(self):
+        # Note: Update pool API is deprecated for removal.
+        LOG.info('Create a pool')
+        pool = self.admin_client.create_pool()[1]
+        self.addCleanup(self.admin_client.delete_pool, pool['id'])
+
+        LOG.info('Update the pool using invalid hostname in ns_records')
+        with self.assertRaisesDns(lib_exc.BadRequest, 'invalid_object', 400):
+            self.admin_client.update_pool(
+                pool['id'],
+                ns_records=[{"hostname": "ns1_example_org_", "priority": 1}])
+
+    @decorators.idempotent_id('3e934624-ac07-11eb-ae06-74e5f9e2a801')
+    def test_update_pool_with_invalid_priority_in_ns_records(self):
+        # Note: Update pool API is deprecated for removal.
+        LOG.info('Create a pool')
+        pool = self.admin_client.create_pool()[1]
+        self.addCleanup(self.admin_client.delete_pool, pool['id'])
+
+        LOG.info('Update the pool using invalid priority in ns_records')
+        with self.assertRaisesDns(lib_exc.BadRequest, 'invalid_object', 400):
+            self.admin_client.update_pool(
+                pool['id'],
+                ns_records=[{"hostname": "ns1.example.org.", "priority": -1}])
diff --git a/designate_tempest_plugin/tests/api/v2/test_ptrs.py b/designate_tempest_plugin/tests/api/v2/test_ptrs.py
new file mode 100644
index 0000000..cfd9685
--- /dev/null
+++ b/designate_tempest_plugin/tests/api/v2/test_ptrs.py
@@ -0,0 +1,125 @@
+# Copyright 2021 Red Hat.
+#
+# 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 tempest import config
+from tempest.lib import decorators
+from tempest.lib import exceptions as lib_exc
+
+from designate_tempest_plugin.tests import base
+import tempest.test
+
+LOG = logging.getLogger(__name__)
+
+CONF = config.CONF
+
+
+class BasePtrTest(base.BaseDnsV2Test):
+    excluded_keys = ['created_at', 'updated_at', 'version', 'links',
+                     'status', 'action']
+
+
+class DesignatePtrRecord(BasePtrTest, tempest.test.BaseTestCase):
+    credentials = ['primary']
+
+    @classmethod
+    def setup_credentials(cls):
+        # Do not create network resources for these test.
+        cls.set_network_resources()
+        super(DesignatePtrRecord, cls).setup_credentials()
+
+    @classmethod
+    def setup_clients(cls):
+        super(DesignatePtrRecord, cls).setup_clients()
+        cls.primary_ptr_client = cls.os_primary.ptr_client
+        cls.primary_floating_ip_client = cls.os_primary.floating_ips_client
+
+    def _set_ptr(self):
+        fip = self.primary_floating_ip_client.create_floatingip(
+            floating_network_id=CONF.network.public_network_id)['floatingip']
+        fip_id = fip['id']
+        self.addCleanup(self.primary_floating_ip_client.delete_floatingip,
+                        fip_id)
+        ptr = self.primary_ptr_client.set_ptr_record(fip_id)
+        self.addCleanup(self.primary_ptr_client.unset_ptr_record, fip_id)
+        self.assertEqual('CREATE', ptr['action'])
+        self.assertEqual('PENDING', ptr['status'])
+        return fip_id, ptr
+
+    @decorators.idempotent_id('2fb9d6ea-871d-11eb-9f9a-74e5f9e2a801')
+    def test_set_floatingip_ptr(self):
+        self._set_ptr()
+
+    @decorators.idempotent_id('9179325a-87d0-11eb-9f9a-74e5f9e2a801')
+    def test_show_floatingip_ptr(self):
+        fip_id, ptr = self._set_ptr()
+        show_ptr = self.primary_ptr_client.show_ptr_record(
+            floatingip_id=fip_id)
+        self.assertExpected(ptr, show_ptr, self.excluded_keys)
+
+    @decorators.idempotent_id('9187a9c6-87d4-11eb-9f9a-74e5f9e2a801')
+    def test_list_floatingip_ptr_records(self):
+        number_of_ptr_records = 3
+        created_ptr_ids = []
+        for _ in range(number_of_ptr_records):
+            fip_id, ptr = self._set_ptr()
+            created_ptr_ids.append(ptr['id'])
+        received_ptr_ids = sorted(
+            [item['id'] for item in
+             self.primary_ptr_client.list_ptr_records()])
+        self.assertEqual(
+            sorted(created_ptr_ids), received_ptr_ids,
+            'Failed - received PTR IDs: {} are not as'
+            ' expected: {}'.format(created_ptr_ids, received_ptr_ids))
+
+    @decorators.idempotent_id('499b5a7e-87e1-11eb-b412-74e5f9e2a801')
+    def test_unset_floatingip_ptr(self):
+        fip_id, ptr = self._set_ptr()
+        self.primary_ptr_client.unset_ptr_record(fip_id)
+
+
+class DesignatePtrRecordNegative(BasePtrTest, tempest.test.BaseTestCase):
+    credentials = ['primary']
+
+    @classmethod
+    def setup_credentials(cls):
+        # Do not create network resources for these test.
+        cls.set_network_resources()
+        super(DesignatePtrRecordNegative, cls).setup_credentials()
+
+    @classmethod
+    def setup_clients(cls):
+        super(DesignatePtrRecordNegative, cls).setup_clients()
+        cls.primary_ptr_client = cls.os_primary.ptr_client
+        cls.primary_floating_ip_client = cls.os_primary.floating_ips_client
+
+    def _set_ptr(self, ptr_name=None, ttl=None, description=None,
+                 headers=None):
+        fip = self.primary_floating_ip_client.create_floatingip(
+            floating_network_id=CONF.network.public_network_id)[
+            'floatingip']
+        fip_id = fip['id']
+        self.addCleanup(self.primary_floating_ip_client.delete_floatingip,
+                        fip_id)
+        ptr = self.primary_ptr_client.set_ptr_record(
+            fip_id, ptr_name=ptr_name, ttl=ttl, description=description,
+            headers=headers)
+        self.addCleanup(self.primary_ptr_client.unset_ptr_record, fip_id)
+        self.assertEqual('CREATE', ptr['action'])
+        self.assertEqual('PENDING', ptr['status'])
+        return fip_id, ptr
+
+    def test_set_floatingip_ptr_invalid_ttl(self):
+        LOG.info('Try to set PTR record using invalid TTL value')
+        with self.assertRaisesDns(lib_exc.BadRequest, 'invalid_object', 400):
+            self._set_ptr(ttl=-10)
diff --git a/designate_tempest_plugin/tests/api/v2/test_recordset.py b/designate_tempest_plugin/tests/api/v2/test_recordset.py
index b8f59fb..96179f1 100644
--- a/designate_tempest_plugin/tests/api/v2/test_recordset.py
+++ b/designate_tempest_plugin/tests/api/v2/test_recordset.py
@@ -281,10 +281,14 @@
 
         LOG.info('Re-Fetch Recordsets as Admin tenant for a Primary project '
                  'using "x-auth-all-projects" HTTP header.')
+        # Note: This is an all-projects list call, so other tests running
+        #       in parallel will impact the list result set. Since the default
+        #       pagination limit is only 20, we set a param limit of 1000 here.
         primary_recordsets_ids = [
             item['id'] for item in self.admin_client.list_recordset(
                 self.zone['id'],
-                headers={'x-auth-all-projects': True})[1]['recordsets']]
+                headers={'x-auth-all-projects': True},
+                params={'limit': 1000})[1]['recordsets']]
 
         for recordset_id in [body_pr_1['id'], body_pr_2['id']]:
             self.assertIn(
@@ -557,9 +561,8 @@
         cls.alt_zone_client = cls.os_alt.zones_client
         cls.admin_client = cls.os_admin.recordset_client
 
-    def _create_client_recordset(self, clients_list=None):
+    def _create_client_recordset(self, clients_list):
         """Create a zone and asoociated recordset using given credentials
-
         :param clients_list: supported credentials are: 'primary' and 'alt'.
         :return: dictionary of created recordsets.
         """
@@ -703,8 +706,11 @@
         project_ids_used = [
             item['project_id'] for item in self._create_client_recordset(
                 ['primary', 'alt']).values()]
+        # Note: This is an all-projects list call, so other tests running
+        #       in parallel will impact the list result set. Since the default
+        #       pagination limit is only 20, we set a param limit of 1000 here.
         recordsets = self.admin_client.list_owned_recordsets(
-            headers={'x-auth-all-projects': True})
+            headers={'x-auth-all-projects': True}, params={'limit': 1000})
         LOG.info('Received by API recordsets are {} '.format(recordsets))
         project_ids_api = set([item['project_id'] for item in recordsets])
         for prj_id in project_ids_used:
diff --git a/designate_tempest_plugin/tests/api/v2/test_tld.py b/designate_tempest_plugin/tests/api/v2/test_tld.py
index 380d224..f14d4ed 100644
--- a/designate_tempest_plugin/tests/api/v2/test_tld.py
+++ b/designate_tempest_plugin/tests/api/v2/test_tld.py
@@ -43,8 +43,14 @@
     @classmethod
     def resource_setup(cls):
         super(TldAdminTest, cls).resource_setup()
-        cls.tld = cls.admin_client.create_tld(tld_name='com',
-                      ignore_errors=lib_exc.Conflict)
+        cls.tld = cls.admin_client.create_tld(
+            tld_name='com', ignore_errors=lib_exc.Conflict
+        )
+
+    @classmethod
+    def resource_cleanup(cls):
+        cls.admin_client.delete_tld(cls.tld[1]['id'])
+        super(TldAdminTest, cls).resource_cleanup()
 
     @decorators.idempotent_id('52a4bb4b-4eff-4591-9dd3-ad98316806c3')
     def test_create_tld(self):
diff --git a/designate_tempest_plugin/tests/api/v2/test_transfer_accepts.py b/designate_tempest_plugin/tests/api/v2/test_transfer_accepts.py
index 1f7c1e1..7cfc4a9 100644
--- a/designate_tempest_plugin/tests/api/v2/test_transfer_accepts.py
+++ b/designate_tempest_plugin/tests/api/v2/test_transfer_accepts.py
@@ -12,6 +12,7 @@
 #    License for the specific language governing permissions and limitations
 #    under the License.
 from oslo_log import log as logging
+from tempest.lib.common.utils import data_utils
 from tempest.lib import decorators
 from tempest.lib import exceptions as lib_exc
 
@@ -56,15 +57,20 @@
     @decorators.idempotent_id('1c6baf97-a83e-4d2e-a5d8-9d37fb7808f3')
     def test_create_transfer_accept(self):
         LOG.info('Create a zone')
-        _, zone = self.prm_zone_client.create_zone()
+        _, zone = self.prm_zone_client.create_zone(wait_until='ACTIVE')
         self.addCleanup(
-            self.wait_zone_delete, self.prm_zone_client, zone['id'])
+            self.wait_zone_delete, self.admin_zone_client, zone['id'],
+            headers={'x-auth-all-projects': True},
+            ignore_errors=lib_exc.NotFound)
 
         LOG.info('Create a zone transfer_request')
         _, transfer_request = self.prm_request_client.create_transfer_request(
             zone['id'])
-        self.addCleanup(self.prm_request_client.delete_transfer_request,
-                        transfer_request['id'])
+        self.addCleanup(
+            self.transfer_request_delete,
+            self.prm_request_client,
+            transfer_request['id']
+        )
 
         data = {
                  "key": transfer_request['key'],
@@ -80,15 +86,20 @@
     @decorators.idempotent_id('37c6afbb-3ea3-4fd8-94ea-a426244f019a')
     def test_show_transfer_accept(self):
         LOG.info('Create a zone')
-        _, zone = self.prm_zone_client.create_zone()
+        _, zone = self.prm_zone_client.create_zone(wait_until='ACTIVE')
         self.addCleanup(
-            self.wait_zone_delete, self.prm_zone_client, zone['id'])
+            self.wait_zone_delete, self.admin_zone_client, zone['id'],
+            headers={'x-auth-all-projects': True},
+            ignore_errors=lib_exc.NotFound)
 
         LOG.info('Create a zone transfer_request')
         _, transfer_request = self.prm_request_client.create_transfer_request(
                                   zone['id'])
-        self.addCleanup(self.prm_request_client.delete_transfer_request,
-                        transfer_request['id'])
+        self.addCleanup(
+            self.transfer_request_delete,
+            self.prm_request_client,
+            transfer_request['id']
+        )
 
         data = {
             "key": transfer_request['key'],
@@ -111,15 +122,20 @@
     def test_ownership_transferred_zone(self):
 
         LOG.info('Create a Primary zone')
-        zone = self.prm_zone_client.create_zone()[1]
-        self.addCleanup(self.wait_zone_delete, self.prm_zone_client,
-                        zone['id'], ignore_errors=lib_exc.NotFound)
+        zone = self.prm_zone_client.create_zone(wait_until='ACTIVE')[1]
+        self.addCleanup(
+            self.wait_zone_delete, self.admin_zone_client, zone['id'],
+            headers={'x-auth-all-projects': True},
+            ignore_errors=lib_exc.NotFound)
 
         LOG.info('Create a Primary zone transfer_request')
         transfer_request = self.prm_request_client.create_transfer_request(
             zone['id'])[1]
-        self.addCleanup(self.prm_request_client.delete_transfer_request,
-                        transfer_request['id'])
+        self.addCleanup(
+            self.transfer_request_delete,
+            self.prm_request_client,
+            transfer_request['id']
+        )
 
         data = {
             "key": transfer_request['key'],
@@ -151,15 +167,20 @@
         for _ in range(number_of_zones_to_transfer):
 
             LOG.info('Create a Primary zone')
-            zone = self.prm_zone_client.create_zone()[1]
-            self.addCleanup(self.wait_zone_delete, self.prm_zone_client,
-                            zone['id'], ignore_errors=lib_exc.NotFound)
+            zone = self.prm_zone_client.create_zone(wait_until='ACTIVE')[1]
+            self.addCleanup(
+                self.wait_zone_delete, self.admin_zone_client, zone['id'],
+                headers={'x-auth-all-projects': True},
+                ignore_errors=lib_exc.NotFound)
 
             LOG.info('Create a Primary zone transfer_request')
             transfer_request = self.prm_request_client.create_transfer_request(
                 zone['id'])[1]
-            self.addCleanup(self.prm_request_client.delete_transfer_request,
-                            transfer_request['id'])
+            self.addCleanup(
+                self.transfer_request_delete,
+                self.prm_request_client,
+                transfer_request['id']
+            )
 
             data = {
                 "key": transfer_request['key'],
@@ -169,17 +190,20 @@
             transfer_accept = self.alt_accept_client.create_transfer_accept(
                 data)[1]
 
-            LOG.info('Ensure we respond with ACTIVE status')
+            LOG.info('Ensure we respond with COMPLETE status')
             self.assertEqual('COMPLETE', transfer_accept['status'])
             transfer_request_ids.append(transfer_accept['id'])
 
         # As Admin list all accepted zone transfers, expected:
         # each previously transferred zone is listed.
+        # Note: This is an all-projects list call, so other tests running
+        #       in parallel will impact the list result set. Since the default
+        #       pagination limit is only 20, we set a param limit of 1000 here.
         LOG.info('Use Admin client to list all "accepted zone transfers"')
         admin_client_accept_ids = [
             item['id'] for item in
             self.admin_accept_client.list_transfer_accept(
-                headers={'x-auth-all-projects': True})]
+                headers={'x-auth-all-projects': True}, params={'limit': 1000})]
         for tr_id in transfer_request_ids:
             self.assertIn(
                 tr_id, admin_client_accept_ids,
@@ -216,3 +240,121 @@
             admin_client_accept_ids,
             "Failed, filtered list should be empty, but actually it's not, "
             "filtered IDs:{} ".format(admin_client_accept_ids))
+
+    @decorators.idempotent_id('b6ac770e-a1d3-11eb-b534-74e5f9e2a801')
+    def test_show_transfer_accept_impersonate_another_project(self):
+        LOG.info('Create a zone as primary tenant')
+        zone = self.prm_zone_client.create_zone(wait_until='ACTIVE')[1]
+
+        # In case when something goes wrong with the test and E2E
+        # scenario fails for some reason, we'll use Admin tenant
+        # to activate Cleanup for a zone.
+        # Note: "ignore_errors=lib_exc.NotFound" is used to prevent a
+        # failure in case when E2E scenario was successfully completed.
+        # Means that Alt tenant has already been able to run a cleanup
+        # for a zone.
+        self.addCleanup(
+            self.wait_zone_delete, self.admin_zone_client, zone['id'],
+            headers={'x-auth-all-projects': True},
+            ignore_errors=lib_exc.NotFound)
+
+        LOG.info('Create a zone transfer_request as primary tenant')
+        transfer_request = self.prm_request_client.create_transfer_request(
+                                  zone['id'])[1]
+        self.addCleanup(
+            self.transfer_request_delete,
+            self.prm_request_client,
+            transfer_request['id']
+        )
+        data = {
+            "key": transfer_request['key'],
+            "zone_transfer_request_id": transfer_request['id']
+        }
+
+        LOG.info('Create a zone transfer_accept for Alt tenant, using '
+                 'Admin client and "sudo" option')
+        transfer_accept = self.admin_accept_client.create_transfer_accept(
+            data, headers={
+                'x-auth-sudo-project-id': self.os_alt.credentials.project_id,
+                'content-type': 'application/json'})[1]
+
+        LOG.info('Fetch the transfer_accept as Alt tenant')
+        body = self.alt_accept_client.show_transfer_accept(
+            transfer_accept['id'])[1]
+
+        LOG.info('Ensure the fetched response matches the '
+                 'created transfer_accept')
+        self.assertExpected(transfer_accept, body, self.excluded_keys)
+
+        # E2E accept zone transfer is done, therefore Alt tenant
+        # should be able to "cleanup" a transferred zone.
+        self.addCleanup(
+            self.wait_zone_delete, self.alt_zone_client, zone['id'])
+
+
+class TransferAcceptTestNegative(BaseTransferAcceptTest):
+
+    credentials = ['primary', 'alt', 'admin']
+
+    @classmethod
+    def setup_credentials(cls):
+        # Do not create network resources for these test.
+        cls.set_network_resources()
+        super(TransferAcceptTestNegative, cls).setup_credentials()
+
+    @classmethod
+    def setup_clients(cls):
+        super(TransferAcceptTestNegative, cls).setup_clients()
+        cls.zone_client = cls.os_primary.zones_client
+        cls.request_client = cls.os_primary.transfer_request_client
+        cls.client = cls.os_primary.transfer_accept_client
+
+    @decorators.idempotent_id('324a3e80-a1cc-11eb-b534-74e5f9e2a801')
+    def test_create_transfer_accept_using_invalid_key(self):
+        LOG.info('Create a zone')
+        zone = self.zone_client.create_zone(wait_until='ACTIVE')[1]
+        self.addCleanup(self.wait_zone_delete, self.zone_client, zone['id'])
+
+        LOG.info('Create a zone transfer_request')
+        transfer_request = self.request_client.create_transfer_request(
+                                  zone['id'])[1]
+        self.addCleanup(
+            self.transfer_request_delete,
+            self.request_client,
+            transfer_request['id']
+        )
+
+        data = {"key": data_utils.rand_password(len(transfer_request['key'])),
+                "zone_transfer_request_id": transfer_request['id']}
+
+        LOG.info('Create a zone transfer_accept using invalid key')
+        self.assertRaises(
+            lib_exc.Forbidden, self.client.create_transfer_accept,
+            transfer_accept_data=data)
+
+    @decorators.idempotent_id('23afb948-a1ce-11eb-b534-74e5f9e2a801')
+    def test_create_transfer_accept_using_deleted_transfer_request_id(self):
+        LOG.info('Create a zone')
+        zone = self.zone_client.create_zone(wait_until='ACTIVE')[1]
+        self.addCleanup(self.wait_zone_delete, self.zone_client, zone['id'])
+
+        LOG.info('Create a zone transfer_request')
+        transfer_request = self.request_client.create_transfer_request(
+                                  zone['id'])[1]
+        self.addCleanup(
+            self.transfer_request_delete,
+            self.request_client,
+            transfer_request['id']
+        )
+
+        data = {
+                 "key": transfer_request['key'],
+                 "zone_transfer_request_id": transfer_request['id']
+        }
+
+        LOG.info('Delete transfer request')
+        self.request_client.delete_transfer_request(transfer_request['id'])
+
+        LOG.info('Ensure 404 when accepting non existing request ID')
+        self.assertRaises(lib_exc.NotFound,
+            lambda: self.client.create_transfer_accept(data))
diff --git a/designate_tempest_plugin/tests/api/v2/test_transfer_request.py b/designate_tempest_plugin/tests/api/v2/test_transfer_request.py
index 666fd79..94a1480 100644
--- a/designate_tempest_plugin/tests/api/v2/test_transfer_request.py
+++ b/designate_tempest_plugin/tests/api/v2/test_transfer_request.py
@@ -228,9 +228,13 @@
 
         LOG.info('List transfer_requests for all projects using Admin tenant '
                  'and "x-auth-all-projects" HTTP header.')
+        # Note: This is an all-projects list call, so other tests running
+        #       in parallel will impact the list result set. Since the default
+        #       pagination limit is only 20, we set a param limit of 1000 here.
         request_ids = [
             item['id'] for item in self.admin_client.list_transfer_requests(
-                headers={'x-auth-all-projects': True})[1]['transfer_requests']]
+                headers={'x-auth-all-projects': True},
+                params={'limit': 1000})[1]['transfer_requests']]
 
         for request_id in [primary_transfer_request['id'],
                            alt_transfer_request['id']]:
diff --git a/designate_tempest_plugin/tests/api/v2/test_zone_tasks.py b/designate_tempest_plugin/tests/api/v2/test_zone_tasks.py
index 5816cd4..12eea8c 100644
--- a/designate_tempest_plugin/tests/api/v2/test_zone_tasks.py
+++ b/designate_tempest_plugin/tests/api/v2/test_zone_tasks.py
@@ -53,9 +53,10 @@
 
     @decorators.idempotent_id('287e2cd0-a0e7-11eb-b962-74e5f9e2a801')
     def test_zone_abandon(self):
-
         LOG.info('Create a PRIMARY zone')
         pr_zone = self.client.create_zone()[1]
+        self.addCleanup(self.wait_zone_delete, self.client, pr_zone['id'])
+        waiters.wait_for_zone_status(self.client, pr_zone['id'], 'ACTIVE')
 
         LOG.info('Ensure we respond with CREATE+PENDING')
         self.assertEqual('CREATE', pr_zone['action'])
@@ -85,6 +86,7 @@
         LOG.info('Create a PRIMARY zone and add to the cleanup')
         pr_zone = self.client.create_zone()[1]
         self.addCleanup(self.wait_zone_delete, self.client, pr_zone['id'])
+        waiters.wait_for_zone_status(self.client, pr_zone['id'], 'ACTIVE')
 
         LOG.info('Ensure we respond with CREATE+PENDING')
         self.assertEqual('CREATE', pr_zone['action'])
@@ -138,6 +140,7 @@
         LOG.info('Create a PRIMARY zone')
         pr_zone = self.client.create_zone()[1]
         self.addCleanup(self.wait_zone_delete, self.client, pr_zone['id'])
+        waiters.wait_for_zone_status(self.client, pr_zone['id'], 'ACTIVE')
 
         LOG.info('Ensure we respond with CREATE+PENDING')
         self.assertEqual('CREATE', pr_zone['action'])
diff --git a/designate_tempest_plugin/tests/api/v2/test_zones.py b/designate_tempest_plugin/tests/api/v2/test_zones.py
index 78b8d4a..c2337fe 100644
--- a/designate_tempest_plugin/tests/api/v2/test_zones.py
+++ b/designate_tempest_plugin/tests/api/v2/test_zones.py
@@ -270,8 +270,12 @@
             self.admin_client, admin_zone['id'], 'ACTIVE')
 
         LOG.info('As admin user list all projects zones')
+        # Note: This is an all-projects list call, so other tests running
+        #       in parallel will impact the list result set. Since the default
+        #       pagination limit is only 20, we set a param limit of 1000 here.
         body = self.admin_client.list_zones(
-            headers={'x-auth-all-projects': True})[1]['zones']
+            headers={'x-auth-all-projects': True},
+            params={'limit': 1000})[1]['zones']
         listed_zone_ids = [item['id'] for item in body]
 
         LOG.info('Ensure the fetched response includes all zone '
diff --git a/designate_tempest_plugin/tests/api/v2/test_zones_exports.py b/designate_tempest_plugin/tests/api/v2/test_zones_exports.py
index e1b0226..cd5a3ac 100644
--- a/designate_tempest_plugin/tests/api/v2/test_zones_exports.py
+++ b/designate_tempest_plugin/tests/api/v2/test_zones_exports.py
@@ -146,9 +146,13 @@
         self.addCleanup(self.alt_client.delete_zone_export, alt_export['id'])
 
         LOG.info('As admin user list zone exports for all projects')
+        # Note: This is an all-projects list call, so other tests running
+        #       in parallel will impact the list result set. Since the default
+        #       pagination limit is only 20, we set a param limit of 1000 here.
         listed_exports_ids = [
             item['id'] for item in self.admin_client.list_zone_exports(
-                headers={'x-auth-all-projects': True})[1]['exports']]
+                headers={'x-auth-all-projects': True},
+                params={'limit': 1000})[1]['exports']]
 
         LOG.info('Make sure that all previously created zone '
                  'export IDs are listed')
diff --git a/designate_tempest_plugin/tests/api/v2/test_zones_imports.py b/designate_tempest_plugin/tests/api/v2/test_zones_imports.py
index 950e021..332899d 100644
--- a/designate_tempest_plugin/tests/api/v2/test_zones_imports.py
+++ b/designate_tempest_plugin/tests/api/v2/test_zones_imports.py
@@ -62,9 +62,9 @@
         LOG.info('Create a zone import')
         _, zone_import = self.client.create_zone_import()
         self.addCleanup(self.clean_up_resources, zone_import['id'])
-
-        LOG.info('Ensure we respond with PENDING')
-        self.assertEqual(const.PENDING, zone_import['status'])
+        # Make sure we complete the import and have the zone_id for cleanup
+        waiters.wait_for_zone_import_status(
+            self.client, zone_import['id'], const.COMPLETE)
 
     @decorators.idempotent_id('31eaf25a-9532-11eb-a55d-74e5f9e2a801')
     def test_create_zone_import_invalid_ttl(self):
@@ -90,6 +90,9 @@
         LOG.info('Create a zone import')
         _, zone_import = self.client.create_zone_import()
         self.addCleanup(self.clean_up_resources, zone_import['id'])
+        # Make sure we complete the import and have the zone_id for cleanup
+        waiters.wait_for_zone_import_status(
+            self.client, zone_import['id'], const.COMPLETE)
 
         LOG.info('Re-Fetch the zone import')
         resp, body = self.client.show_zone_import(zone_import['id'])
@@ -120,6 +123,9 @@
         LOG.info('Create a zone import')
         _, zone_import = self.client.create_zone_import()
         self.addCleanup(self.clean_up_resources, zone_import['id'])
+        # Make sure we complete the import and have the zone_id for cleanup
+        waiters.wait_for_zone_import_status(
+            self.client, zone_import['id'], const.COMPLETE)
 
         LOG.info('List zones imports')
         _, body = self.client.list_zone_imports()
@@ -133,8 +139,9 @@
         zone_import = self.client.create_zone_import()[1]
         self.addCleanup(self.clean_up_resources, zone_import['id'])
 
-        LOG.info('Ensure we respond with PENDING')
-        self.assertEqual(const.PENDING, zone_import['status'])
+        # Make sure we complete the import and have the zone_id for cleanup
+        waiters.wait_for_zone_import_status(
+            self.client, zone_import['id'], const.COMPLETE)
 
         LOG.info('Show a zone import for a Primary tenant, using Alt tenant. '
                  'Expected:404 NotFound')
@@ -173,6 +180,9 @@
         LOG.info('Create import zone "A" using primary client')
         zone_import = self.client.create_zone_import()[1]
         self.addCleanup(self.clean_up_resources, zone_import['id'])
+        # Make sure we complete the import and have the zone_id for cleanup
+        waiters.wait_for_zone_import_status(
+            self.client, zone_import['id'], const.COMPLETE)
 
         LOG.info('As Alt user list import zones for a Primary tenant, '
                  'using "x-auth-sudo-project-id" HTTP header. '
@@ -189,8 +199,12 @@
                 headers={'x-auth-all-projects': True}))
 
         LOG.info('As Admin tenant list import zones for all projects')
+        # Note: This is an all-projects list call, so other tests running
+        #       in parallel will impact the list result set. Since the default
+        #       pagination limit is only 20, we set a param limit of 1000 here.
         body = self.admin_client.list_zone_imports(headers={
-                'x-auth-all-projects': True})[1]['imports']
+                'x-auth-all-projects': True},
+                params={'limit': 1000})[1]['imports']
 
         LOG.info('Ensure the fetched response includes previously '
                  'created import ID')
diff --git a/designate_tempest_plugin/tests/base.py b/designate_tempest_plugin/tests/base.py
index 6d33d81..a9a5e03 100644
--- a/designate_tempest_plugin/tests/base.py
+++ b/designate_tempest_plugin/tests/base.py
@@ -98,14 +98,22 @@
         with context:
             callable_(*args, **kwargs)
 
+    def transfer_request_delete(self, transfer_client, transfer_request_id):
+        return utils.call_and_ignore_notfound_exc(
+            transfer_client.delete_transfer_request, transfer_request_id)
+
     def wait_zone_delete(self, zone_client, zone_id, **kwargs):
-        zone_client.delete_zone(zone_id, **kwargs)
+        self._delete_zone(zone_client, zone_id, **kwargs)
         utils.call_until_true(self._check_zone_deleted,
                               CONF.dns.build_timeout,
                               CONF.dns.build_interval,
                               zone_client,
                               zone_id)
 
+    def _delete_zone(self, zone_client, zone_id, **kwargs):
+        return utils.call_and_ignore_notfound_exc(zone_client.delete_zone,
+                                                  zone_id, **kwargs)
+
     def _check_zone_deleted(self, zone_client, zone_id):
         return utils.call_and_ignore_notfound_exc(zone_client.show_zone,
                                                   zone_id) is None
diff --git a/doc/requirements.txt b/doc/requirements.txt
new file mode 100644
index 0000000..710f18d
--- /dev/null
+++ b/doc/requirements.txt
@@ -0,0 +1,9 @@
+# The order of packages is significant, because pip processes them in the order
+# of appearance. Changing the order has an impact on the overall integration
+# process, which may cause wedges in the gate later.
+
+sphinx>=2.0.0,!=2.1.0 # BSD
+openstackdocstheme>=2.2.1 # Apache-2.0
+
+# releasenotes
+reno>=3.1.0 # Apache-2.0
diff --git a/releasenotes/source/_static/.placeholder b/releasenotes/source/_static/.placeholder
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/releasenotes/source/_static/.placeholder
diff --git a/releasenotes/source/_templates/.placeholder b/releasenotes/source/_templates/.placeholder
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/releasenotes/source/_templates/.placeholder
diff --git a/releasenotes/source/conf.py b/releasenotes/source/conf.py
new file mode 100644
index 0000000..7058407
--- /dev/null
+++ b/releasenotes/source/conf.py
@@ -0,0 +1,268 @@
+# 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.
+
+# Designate Release Notes documentation build configuration file, created by
+# sphinx-quickstart on Tue Nov  3 17:40:50 2015.
+#
+# This file is execfile()d with the current directory set to its
+# containing dir.
+#
+# Note that not all possible configuration values are present in this
+# autogenerated file.
+#
+# All configuration values have a default; values that are commented out
+# serve to show the default.
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+# sys.path.insert(0, os.path.abspath('.'))
+
+# -- General configuration ------------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+# needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = [
+    'openstackdocstheme',
+    'reno.sphinxext',
+]
+
+# openstackdocstheme options
+openstackdocs_repo_name = 'openstack/designate-tempest-plugin'
+openstackdocs_bug_project = 'designate'
+openstackdocs_bug_tag = ''
+openstackdocs_auto_name = False
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# The suffix of source filenames.
+source_suffix = '.rst'
+
+# The encoding of source files.
+# source_encoding = 'utf-8-sig'
+
+# The master toctree document.
+master_doc = 'index'
+
+# General information about the project.
+copyright = u'2021, Designate Developers'
+
+# Release notes are versions independent.
+# The short X.Y version.
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+# language = None
+
+# There are two options for replacing |today|: either, you set today to some
+# non-false value, then it is used:
+# today = ''
+# Else, today_fmt is used as the format for a strftime call.
+# today_fmt = '%B %d, %Y'
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+exclude_patterns = []
+
+# The reST default role (used for this markup: `text`) to use for all
+# documents.
+# default_role = None
+
+# If true, '()' will be appended to :func: etc. cross-reference text.
+# add_function_parentheses = True
+
+# If true, the current module name will be prepended to all description
+# unit titles (such as .. function::).
+# add_module_names = True
+
+# If true, sectionauthor and moduleauthor directives will be shown in the
+# output. They are ignored by default.
+# show_authors = False
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = 'native'
+
+# A list of ignored prefixes for module index sorting.
+# modindex_common_prefix = []
+
+# If true, keep warnings as "system message" paragraphs in the built documents.
+# keep_warnings = False
+
+
+# -- Options for HTML output ----------------------------------------------
+
+# The theme to use for HTML and HTML Help pages.  See the documentation for
+# a list of builtin themes.
+html_theme = 'openstackdocs'
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further.  For a list of options available for each theme, see the
+# documentation.
+# html_theme_options = {}
+
+# Add any paths that contain custom themes here, relative to this directory.
+# html_theme_path = []
+
+# The name for this set of Sphinx documents.  If None, it defaults to
+# "<project> v<release> documentation".
+# html_title = None
+
+# A shorter title for the navigation bar.  Default is the same as html_title.
+# html_short_title = None
+
+# The name of an image file (relative to this directory) to place at the top
+# of the sidebar.
+# html_logo = None
+
+# The name of an image file (within the static path) to use as favicon of the
+# docs.  This file should be a Windows icon file (.ico) being 16x16 or 32x32
+# pixels large.
+# html_favicon = None
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']
+
+# Add any extra paths that contain custom files (such as robots.txt or
+# .htaccess) here, relative to this directory. These files are copied
+# directly to the root of the documentation.
+# html_extra_path = []
+
+# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
+# using the given strftime format.
+# html_last_updated_fmt = '%b %d, %Y'
+
+# If true, SmartyPants will be used to convert quotes and dashes to
+# typographically correct entities.
+# html_use_smartypants = True
+
+# Custom sidebar templates, maps document names to template names.
+# html_sidebars = {}
+
+# Additional templates that should be rendered to pages, maps page names to
+# template names.
+# html_additional_pages = {}
+
+# If false, no module index is generated.
+# html_domain_indices = True
+
+# If false, no index is generated.
+# html_use_index = True
+
+# If true, the index is split into individual pages for each letter.
+# html_split_index = False
+
+# If true, links to the reST sources are added to the pages.
+# html_show_sourcelink = True
+
+# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
+# html_show_sphinx = True
+
+# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
+# html_show_copyright = True
+
+# If true, an OpenSearch description file will be output, and all pages will
+# contain a <link> tag referring to it.  The value of this option must be the
+# base URL from which the finished HTML is served.
+# html_use_opensearch = ''
+
+# This is the file name suffix for HTML files (e.g. ".xhtml").
+# html_file_suffix = None
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = 'DesignateTempestPluginReleaseNotesdoc'
+
+
+# -- Options for LaTeX output ---------------------------------------------
+
+# Grouping the document tree into LaTeX files. List of tuples
+# (source start file, target name, title,
+#  author, documentclass [howto, manual, or own class]).
+latex_documents = [
+    ('index', 'DesignateTempestPluginReleaseNotes.tex',
+     u'Designate Tempest Plugin Release Notes '
+     u'Documentation',
+     u'Designate Tempest Plugin Developers', 'manual'),
+]
+
+# The name of an image file (relative to this directory) to place at the top of
+# the title page.
+# latex_logo = None
+
+# For "manual" documents, if this is true, then toplevel headings are parts,
+# not chapters.
+# latex_use_parts = False
+
+# If true, show page references after internal links.
+# latex_show_pagerefs = False
+
+# If true, show URL addresses after external links.
+# latex_show_urls = False
+
+# Documents to append as an appendix to all manuals.
+# latex_appendices = []
+
+# If false, no module index is generated.
+# latex_domain_indices = True
+
+
+# -- Options for manual page output ---------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [
+    ('index', 'DesignateTempestPluginreleasenotes',
+     u'Designate Tempest Plugin Release Notes '
+     u'Documentation',
+     [u'Designate Tempest Plugin Developers'], 1)
+]
+
+# If true, show URL addresses after external links.
+# man_show_urls = False
+
+
+# -- Options for Texinfo output -------------------------------------------
+
+# Grouping the document tree into Texinfo files. List of tuples
+# (source start file, target name, title, author,
+#  dir menu entry, description, category)
+texinfo_documents = [
+    ('index', 'DesignateTempestPluginReleaseNotes',
+     u'Designate Tempest Plugin Release Notes '
+     u'Documentation',
+     u'Designate Tempest Plugin Developers',
+     'DesignateTempestPluginReleaseNotes',
+     'The tempest plugin for the Designate test suite.',
+     'Miscellaneous'),
+]
+
+# Documents to append as an appendix to all manuals.
+# texinfo_appendices = []
+
+# If false, no module index is generated.
+# texinfo_domain_indices = True
+
+# How to display URL addresses: 'footnote', 'no', or 'inline'.
+# texinfo_show_urls = 'footnote'
+
+# If true, do not generate a @detailmenu in the "Top" node's menu.
+# texinfo_no_detailmenu = False
+
+# -- Options for Internationalization output ------------------------------
+locale_dirs = ['locale/']
diff --git a/releasenotes/source/index.rst b/releasenotes/source/index.rst
new file mode 100644
index 0000000..cf4c77e
--- /dev/null
+++ b/releasenotes/source/index.rst
@@ -0,0 +1,18 @@
+..
+      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.
+
+======================================
+Designate Tempest Plugin Release Notes
+======================================
+
+.. release-notes::
diff --git a/test-requirements.txt b/test-requirements.txt
index df0e096..c0a7610 100644
--- a/test-requirements.txt
+++ b/test-requirements.txt
@@ -4,5 +4,3 @@
 
 # Hacking already pins down pep8/pycodestyle pyflakes and flake8
 hacking>=3.0.1,<3.1.0 # Apache-2.0
-openstackdocstheme>=2.2.1 # Apache-2.0
-sphinx>=2.0.0,!=2.1.0 # BSD
diff --git a/tox.ini b/tox.ini
index b7f3098..f1581bc 100644
--- a/tox.ini
+++ b/tox.ini
@@ -35,11 +35,19 @@
 [testenv:pep8]
 commands = sh tools/pretty_flake8.sh
 
-
 [testenv:docs]
+deps = -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master}
+       -r{toxinidir}/doc/requirements.txt
 commands = rm -rf doc/build
            sphinx-build -E -W -b html doc/source doc/build/html
 
+[testenv:releasenotes]
+deps = {[testenv:docs]deps}
+whitelist_externals = rm
+commands =
+  rm -rf releasenotes/build
+  sphinx-build -a -E -W -d releasenotes/build/doctrees -b html releasenotes/source releasenotes/build/html
+
 [testenv:bashate]
 deps = bashate
 whitelist_externals = bash