Merge "Add logs for test_floatingip_port_details"
diff --git a/neutron_tempest_plugin/api/admin/test_logging.py b/neutron_tempest_plugin/api/admin/test_logging.py
index f4cbe29..b76377d 100644
--- a/neutron_tempest_plugin/api/admin/test_logging.py
+++ b/neutron_tempest_plugin/api/admin/test_logging.py
@@ -28,9 +28,11 @@
 
     @decorators.idempotent_id('8d2e1ba5-455b-4519-a88e-e587002faba6')
     def test_log_lifecycle(self):
+        security_group = self.create_security_group()
         name = data_utils.rand_name('test-log')
         description = data_utils.rand_name('test-log-desc')
         log = self.create_log(name=name, description=description,
+                              resource_id=security_group['id'],
                               resource_type='security_group', enabled=True)
 
         # Test 'show log'
@@ -72,3 +74,27 @@
         # Verify that only required fields present in logging types
         for log_type in actual_list_log_types:
             self.assertEqual(tuple(expected_log_keys), tuple(log_type.keys()))
+
+    @decorators.idempotent_id('1ab4eb2a-76f5-45b9-816b-1aa497a71eea')
+    def test_log_deleted_with_corresponding_security_group(self):
+        security_group = self.create_security_group()
+        name = data_utils.rand_name('test-log')
+        log = self.create_log(
+            name=name,
+            resource_type='security_group',
+            resource_id=security_group['id'],
+            enabled=True)
+
+        # Ensure log was created
+        retrieved_log = self.admin_client.show_log(log['id'])['log']
+        self.assertEqual(name, retrieved_log['name'])
+        self.assertEqual(security_group['id'], retrieved_log['resource_id'])
+        self.assertEqual('security_group', retrieved_log['resource_type'])
+        self.assertTrue(retrieved_log['enabled'])
+
+        # Delete SG
+        self.delete_security_group(security_group)
+
+        # Ensure log is also deleted
+        self.assertRaises(exceptions.NotFound,
+                          self.admin_client.show_log, log['id'])
diff --git a/neutron_tempest_plugin/api/admin/test_ports.py b/neutron_tempest_plugin/api/admin/test_ports.py
index b277fac..a374b81 100644
--- a/neutron_tempest_plugin/api/admin/test_ports.py
+++ b/neutron_tempest_plugin/api/admin/test_ports.py
@@ -133,14 +133,41 @@
         self.assertIn('resource_request', port)
         vnic_trait = 'CUSTOM_VNIC_TYPE_%s' % vnic_type.upper()
         physnet_trait = 'CUSTOM_PHYSNET_%s' % self.physnet_name.upper()
-        self.assertCountEqual([physnet_trait, vnic_trait],
-                              port['resource_request']['required'])
+        if utils.is_extension_enabled('port-resource-request-groups',
+                                      'network'):
+            min_bw_group_found = False
+            for rg in port['resource_request']['request_groups']:
+                self.assertIn(rg['id'],
+                              port['resource_request']['same_subtree'])
+                if (('NET_BW_EGR_KILOBIT_PER_SEC' in rg['resources'] or
+                        'NET_BW_IGR_KILOBIT_PER_SEC' in rg['resources']) and
+                        not min_bw_group_found):
+                    self.assertCountEqual([physnet_trait, vnic_trait],
+                                          rg['required'])
 
-        self.assertEqual(
-            {'NET_BW_EGR_KILOBIT_PER_SEC': self.EGRESS_KBPS,
-             'NET_BW_IGR_KILOBIT_PER_SEC': self.INGRESS_KBPS},
-            port['resource_request']['resources']
-        )
+                    self.assertEqual(
+                        {'NET_BW_EGR_KILOBIT_PER_SEC': self.EGRESS_KBPS,
+                        'NET_BW_IGR_KILOBIT_PER_SEC': self.INGRESS_KBPS},
+                        rg['resources']
+                    )
+                    min_bw_group_found = True
+                else:
+                    self.fail('"resource_request" contains unexpected request '
+                              'group: %s', rg)
+
+            if not min_bw_group_found:
+                self.fail('Did not find expected request groups in '
+                          '"resource_request": %s',
+                          port['resource_request']['request_groups'])
+        else:
+            self.assertCountEqual([physnet_trait, vnic_trait],
+                                  port['resource_request']['required'])
+
+            self.assertEqual(
+                {'NET_BW_EGR_KILOBIT_PER_SEC': self.EGRESS_KBPS,
+                'NET_BW_IGR_KILOBIT_PER_SEC': self.INGRESS_KBPS},
+                port['resource_request']['resources']
+            )
 
     @decorators.idempotent_id('ebb86dc4-716c-4558-8516-6dfc4a67601f')
     def test_port_resource_request(self):
diff --git a/neutron_tempest_plugin/api/base.py b/neutron_tempest_plugin/api/base.py
index ecdd00a..07fcb0b 100644
--- a/neutron_tempest_plugin/api/base.py
+++ b/neutron_tempest_plugin/api/base.py
@@ -118,6 +118,8 @@
         cls.routers = []
         cls.floating_ips = []
         cls.port_forwardings = []
+        cls.local_ips = []
+        cls.local_ip_associations = []
         cls.metering_labels = []
         cls.service_profiles = []
         cls.flavors = []
@@ -167,6 +169,15 @@
             for floating_ip in cls.floating_ips:
                 cls._try_delete_resource(cls.delete_floatingip, floating_ip)
 
+            # Clean up Local IP Associations
+            for association in cls.local_ip_associations:
+                cls._try_delete_resource(cls.delete_local_ip_association,
+                                         association)
+            # Clean up Local IPs
+            for local_ip in cls.local_ips:
+                cls._try_delete_resource(cls.delete_local_ip,
+                                         local_ip)
+
             # Clean up conntrack helpers
             for cth in cls.conntrack_helpers:
                 cls._try_delete_resource(cls.delete_conntrack_helper, cth)
@@ -732,6 +743,98 @@
         client = client or pf.get('client') or cls.client
         client.delete_port_forwarding(pf['floatingip_id'], pf['id'])
 
+    def create_local_ip(cls, network_id=None,
+                        client=None, **kwargs):
+        """Creates a Local IP.
+
+        Create a Local IP and schedule it for later deletion.
+        If a client is passed, then it is used for deleting the IP too.
+
+        :param network_id: network ID where to create
+        By default this is 'CONF.network.public_network_id'.
+
+        :param client: network client to be used for creating and cleaning up
+        the Local IP.
+
+        :param **kwargs: additional creation parameters to be forwarded to
+        networking server.
+        """
+
+        client = client or cls.client
+        network_id = (network_id or
+                      cls.external_network_id)
+
+        local_ip = client.create_local_ip(network_id,
+                                          **kwargs)['local_ip']
+
+        # save client to be used later in cls.delete_local_ip
+        # for final cleanup
+        local_ip['client'] = client
+        cls.local_ips.append(local_ip)
+        return local_ip
+
+    @classmethod
+    def delete_local_ip(cls, local_ip, client=None):
+        """Delete Local IP
+
+        :param client: Client to be used
+        If client is not given it will use the client used to create
+        the Local IP, or cls.client if unknown.
+        """
+
+        client = client or local_ip.get('client') or cls.client
+        client.delete_local_ip(local_ip['id'])
+
+    @classmethod
+    def create_local_ip_association(cls, local_ip_id, fixed_port_id,
+                                    fixed_ip_address=None, client=None):
+        """Creates a Local IP association.
+
+        Create a Local IP Association and schedule it for later deletion.
+        If a client is passed, then it is used for deleting the association
+        too.
+
+        :param local_ip_id: The ID of the Local IP.
+
+        :param fixed_port_id: The ID of the Neutron port
+        to be associated with the Local IP
+
+        :param fixed_ip_address: The fixed IPv4 address of the Neutron
+        port to be associated with the Local IP
+
+        :param client: network client to be used for creating and cleaning up
+        the Local IP Association.
+        """
+
+        client = client or cls.client
+
+        association = client.create_local_ip_association(
+            local_ip_id, fixed_port_id,
+            fixed_ip_address)['port_association']
+
+        # save ID of Local IP  for final cleanup
+        association['local_ip_id'] = local_ip_id
+
+        # save client to be used later in
+        # cls.delete_local_ip_association for final cleanup
+        association['client'] = client
+        cls.local_ip_associations.append(association)
+        return association
+
+    @classmethod
+    def delete_local_ip_association(cls, association, client=None):
+
+        """Delete Local IP Association
+
+        :param client: Client to be used
+        If client is not given it will use the client used to create
+        the local IP association, or cls.client if unknown.
+        """
+
+        client = client or association.get('client') or cls.client
+        client.delete_local_ip_association(association['local_ip_id'],
+                                           association['fixed_port_id'])
+
     @classmethod
     def create_router_interface(cls, router_id, subnet_id):
         """Wrapper utility that returns a router interface."""
@@ -1105,12 +1208,13 @@
                    target_id=None, event='ALL', enabled=True):
         """Wrapper utility that returns a test log object."""
         log_args = {'name': name,
-                    'description': description,
                     'resource_type': resource_type,
                     'resource_id': resource_id,
                     'target_id': target_id,
                     'event': event,
                     'enabled': enabled}
+        if description:
+            log_args['description'] = description
         body = cls.admin_client.create_log(**log_args)
         log_object = body['log']
         cls.log_objects.append(log_object)
diff --git a/neutron_tempest_plugin/api/clients.py b/neutron_tempest_plugin/api/clients.py
index 6565dcb..2855a7a 100644
--- a/neutron_tempest_plugin/api/clients.py
+++ b/neutron_tempest_plugin/api/clients.py
@@ -24,6 +24,7 @@
 from tempest.lib.services.identity.v3 import projects_client
 from tempest.lib.services.network import qos_limit_bandwidth_rules_client
 from tempest.lib.services.network import qos_minimum_bandwidth_rules_client
+from tempest.lib.services.network import qos_minimum_packet_rate_rules_client
 
 from neutron_tempest_plugin import config
 from neutron_tempest_plugin.services.network.json import network_client
@@ -114,6 +115,17 @@
                 build_timeout=CONF.network.build_timeout,
                 **self.default_params)
 
+        self.qos_minimum_packet_rate_rules_client = \
+            qos_minimum_packet_rate_rules_client.\
+            QosMinimumPacketRateRulesClient(
+                self.auth_provider,
+                CONF.network.catalog_type,
+                CONF.network.region or CONF.identity.region,
+                endpoint_type=CONF.network.endpoint_type,
+                build_interval=CONF.network.build_interval,
+                build_timeout=CONF.network.build_timeout,
+                **self.default_params)
+
     def _set_identity_clients(self):
         params = {
             'service': CONF.identity.catalog_type,
diff --git a/neutron_tempest_plugin/api/test_local_ip.py b/neutron_tempest_plugin/api/test_local_ip.py
new file mode 100644
index 0000000..3895f4f
--- /dev/null
+++ b/neutron_tempest_plugin/api/test_local_ip.py
@@ -0,0 +1,142 @@
+#   Copyright 2021 Huawei, Inc. 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.
+#
+
+from tempest.lib import decorators
+from tempest.lib import exceptions
+
+from neutron_tempest_plugin.api import base
+from neutron_tempest_plugin import config
+
+CONF = config.CONF
+
+
+class LocalIPTestJSON(base.BaseNetworkTest):
+
+    credentials = ['primary', 'admin']
+    required_extensions = ['local_ip']
+
+    @classmethod
+    def resource_setup(cls):
+        super(LocalIPTestJSON, cls).resource_setup()
+        cls.ext_net_id = CONF.network.public_network_id
+
+        # Create network and subnet
+        cls.network = cls.create_network()
+        cls.subnet = cls.create_subnet(cls.network)
+
+    @decorators.idempotent_id('369257b0-521d-43f5-9482-50e18e87a472')
+    def test_local_ip_lifecycle(self):
+        port = self.create_port(self.network)
+        lip_description = 'Test Local IP description'
+        lip_name = 'test-local-ip'
+        created_local_ip = self.create_local_ip(
+            name=lip_name,
+            description=lip_description,
+            local_port_id=port['id'],
+            local_ip_address=port['fixed_ips'][0]['ip_address'])
+        self.assertEqual(self.network['id'], created_local_ip['network_id'])
+        self.assertEqual(lip_description, created_local_ip['description'])
+        self.assertEqual(lip_name, created_local_ip['name'])
+        self.assertEqual(port['id'], created_local_ip['local_port_id'])
+        self.assertEqual(port['fixed_ips'][0]['ip_address'],
+                         created_local_ip['local_ip_address'])
+
+        # Show created local_ip
+        body = self.client.get_local_ip(created_local_ip['id'])
+        local_ip = body['local_ip']
+
+        self.assertEqual(lip_description, local_ip['description'])
+        self.assertEqual(lip_name, local_ip['name'])
+
+        # List local_ips
+        body = self.client.list_local_ips()
+
+        local_ip_ids = [lip['id'] for lip in body['local_ips']]
+        self.assertIn(created_local_ip['id'], local_ip_ids)
+
+        # Update local_ip
+        updated_local_ip = self.client.update_local_ip(
+                               created_local_ip['id'],
+                               name='updated_local_ip')
+        self.assertEqual('updated_local_ip',
+                         updated_local_ip['local_ip']['name'])
+
+        self.delete_local_ip(created_local_ip)
+        self.assertRaises(exceptions.NotFound,
+                          self.client.get_local_ip, created_local_ip['id'])
+
+    @decorators.idempotent_id('e32df8ac-4e29-4adf-8057-46ae8684eff2')
+    def test_create_local_ip_with_network(self):
+        local_ip = self.create_local_ip(self.network['id'])
+        self.assertEqual(self.network['id'], local_ip['network_id'])
+
+
+class LocalIPAssociationTestJSON(base.BaseNetworkTest):
+
+    required_extensions = ['local_ip']
+
+    @classmethod
+    def resource_setup(cls):
+        super(LocalIPAssociationTestJSON, cls).resource_setup()
+        cls.ext_net_id = CONF.network.public_network_id
+        # Create network
+        cls.network = cls.create_network()
+        cls.subnet = cls.create_subnet(cls.network)
+
+    @decorators.idempotent_id('602d2874-49be-4c72-8799-b20c95853b6b')
+    def test_local_ip_association_lifecycle(self):
+        local_ip = self.create_local_ip(self.network['id'])
+        port = self.create_port(self.network)
+        local_ip_association = self.create_local_ip_association(
+            local_ip['id'],
+            fixed_port_id=port['id'])
+        self.assertEqual(local_ip['id'], local_ip_association['local_ip_id'])
+        self.assertEqual(port['id'], local_ip_association['fixed_port_id'])
+
+        # Test List Local IP Associations
+        body = self.client.list_local_ip_associations(local_ip['id'])
+        associations = body['port_associations']
+        self.assertEqual(local_ip['id'], associations[0]['local_ip_id'])
+        self.assertEqual(port['id'], associations[0]['fixed_port_id'])
+
+        # Show
+        body = self.client.get_local_ip_association(
+            local_ip['id'], port['id'])
+        association = body['port_association']
+        self.assertEqual(local_ip['id'], association['local_ip_id'])
+        self.assertEqual(port['id'], association['fixed_port_id'])
+
+        # Delete
+        self.client.delete_local_ip_association(local_ip['id'], port['id'])
+        self.assertRaises(exceptions.NotFound,
+                          self.client.get_local_ip_association,
+                          local_ip['id'], port['id'])
+
+    @decorators.idempotent_id('5d26edab-78d2-4cbd-9d0b-3c0b19f0f52d')
+    def test_local_ip_association_with_two_ips_on_port(self):
+        local_ip = self.create_local_ip(self.network['id'])
+        s = self.subnet
+        port = self.create_port(self.network)
+        # request another IP on the same subnet
+        port['fixed_ips'].append({'subnet_id': s['id']})
+        updated = self.client.update_port(port['id'],
+                                          fixed_ips=port['fixed_ips'])
+        port = updated['port']
+        local_ip_association = self.create_local_ip_association(
+            local_ip['id'],
+            fixed_port_id=port['id'],
+            fixed_ip_address=port['fixed_ips'][0]['ip_address'])
+        self.assertEqual(port['fixed_ips'][0]['ip_address'],
+                         local_ip_association['fixed_ip'])
diff --git a/neutron_tempest_plugin/api/test_qos.py b/neutron_tempest_plugin/api/test_qos.py
index 5284688..ebce45b 100644
--- a/neutron_tempest_plugin/api/test_qos.py
+++ b/neutron_tempest_plugin/api/test_qos.py
@@ -1376,6 +1376,226 @@
         self.assertNotIn(rule2['id'], rules_ids)
 
 
+class QosMinimumPpsRuleTestJSON(base.BaseAdminNetworkTest):
+    RULE_NAME = qos_consts.RULE_TYPE_MINIMUM_PACKET_RATE + "_rule"
+    RULES_NAME = RULE_NAME + "s"
+    required_extensions = [qos_apidef.ALIAS]
+
+    @classmethod
+    @utils.requires_ext(service='network',
+                        extension='port-resource-request-groups')
+    def resource_setup(cls):
+        super(QosMinimumPpsRuleTestJSON, cls).resource_setup()
+
+    @classmethod
+    def setup_clients(cls):
+        super(QosMinimumPpsRuleTestJSON, cls).setup_clients()
+        cls.min_pps_client = cls.os_admin.qos_minimum_packet_rate_rules_client
+        cls.min_pps_client_primary = \
+            cls.os_primary.qos_minimum_packet_rate_rules_client
+
+    def _create_qos_min_pps_rule(self, policy_id, rule_data):
+        rule = self.min_pps_client.create_minimum_packet_rate_rule(
+            policy_id, **rule_data)['minimum_packet_rate_rule']
+        self.addCleanup(
+            test_utils.call_and_ignore_notfound_exc,
+            self.min_pps_client.delete_minimum_packet_rate_rule,
+            policy_id, rule['id'])
+        return rule
+
+    @decorators.idempotent_id('66a5b9b4-d4f9-4af8-b238-9e1881b78487')
+    def test_rule_create(self):
+        policy = self.create_qos_policy(name='test-policy',
+                                        description='test policy',
+                                        shared=False)
+        rule = self._create_qos_min_pps_rule(
+            policy['id'],
+            {qos_consts.DIRECTION: n_constants.EGRESS_DIRECTION,
+             qos_consts.MIN_KPPS: 1138})
+
+        # Test 'show rule'
+        retrieved_rule = self.min_pps_client.show_minimum_packet_rate_rule(
+            policy['id'], rule['id'])[self.RULE_NAME]
+        self.assertEqual(rule['id'], retrieved_rule['id'])
+        self.assertEqual(1138, retrieved_rule[qos_consts.MIN_KPPS])
+        self.assertEqual(n_constants.EGRESS_DIRECTION,
+                         retrieved_rule[qos_consts.DIRECTION])
+
+        # Test 'list rules'
+        rules = self.min_pps_client.list_minimum_packet_rate_rules(
+            policy['id'])
+        rules = rules[self.RULES_NAME]
+        rules_ids = [r['id'] for r in rules]
+        self.assertIn(rule['id'], rules_ids)
+
+        # Test 'show policy'
+        retrieved_policy = self.admin_client.show_qos_policy(policy['id'])
+        policy_rules = retrieved_policy['policy']['rules']
+        self.assertEqual(1, len(policy_rules))
+        self.assertEqual(rule['id'], policy_rules[0]['id'])
+        self.assertEqual('minimum_packet_rate',
+                         policy_rules[0]['type'])
+
+    @decorators.idempotent_id('6b656b57-d2bf-47f9-89a9-1baad1bd5418')
+    def test_rule_create_fail_for_missing_min_kpps(self):
+        policy = self.create_qos_policy(name='test-policy',
+                                        description='test policy',
+                                        shared=False)
+        self.assertRaises(exceptions.BadRequest,
+                          self._create_qos_min_pps_rule,
+                          policy['id'],
+                          {qos_consts.DIRECTION: n_constants.EGRESS_DIRECTION})
+
+    @decorators.idempotent_id('f41213e5-2ab8-4916-b106-38d2cac5e18c')
+    def test_rule_create_fail_for_the_same_type(self):
+        policy = self.create_qos_policy(name='test-policy',
+                                        description='test policy',
+                                        shared=False)
+        self._create_qos_min_pps_rule(policy['id'],
+            {qos_consts.DIRECTION: n_constants.EGRESS_DIRECTION,
+             qos_consts.MIN_KPPS: 200})
+
+        self.assertRaises(exceptions.Conflict,
+                          self._create_qos_min_pps_rule,
+                          policy['id'],
+                          {qos_consts.DIRECTION: n_constants.EGRESS_DIRECTION,
+                           qos_consts.MIN_KPPS: 201})
+
+    @decorators.idempotent_id('ceb8e41e-3d72-11ec-a446-d7faae6daec2')
+    def test_rule_create_any_direction_when_egress_direction_exists(self):
+        policy = self.create_qos_policy(name='test-policy',
+                                        description='test policy',
+                                        shared=False)
+        self._create_qos_min_pps_rule(policy['id'],
+            {qos_consts.DIRECTION: n_constants.EGRESS_DIRECTION,
+             qos_consts.MIN_KPPS: 200})
+
+        self.assertRaises(exceptions.Conflict,
+                          self._create_qos_min_pps_rule,
+                          policy['id'],
+                          {qos_consts.DIRECTION: n_constants.ANY_DIRECTION,
+                           qos_consts.MIN_KPPS: 201})
+
+    @decorators.idempotent_id('a147a71e-3d7b-11ec-8097-278b1afd5fa2')
+    def test_rule_create_egress_direction_when_any_direction_exists(self):
+        policy = self.create_qos_policy(name='test-policy',
+                                        description='test policy',
+                                        shared=False)
+        self._create_qos_min_pps_rule(policy['id'],
+            {qos_consts.DIRECTION: n_constants.ANY_DIRECTION,
+             qos_consts.MIN_KPPS: 200})
+
+        self.assertRaises(exceptions.Conflict,
+                          self._create_qos_min_pps_rule,
+                          policy['id'],
+                          {qos_consts.DIRECTION: n_constants.EGRESS_DIRECTION,
+                           qos_consts.MIN_KPPS: 201})
+
+    @decorators.idempotent_id('522ed09a-1d7f-4c1b-9195-61f19caf916f')
+    def test_rule_update(self):
+        policy = self.create_qos_policy(name='test-policy',
+                                        description='test policy',
+                                        shared=False)
+        rule = self._create_qos_min_pps_rule(
+            policy['id'],
+            {qos_consts.DIRECTION: n_constants.EGRESS_DIRECTION,
+             qos_consts.MIN_KPPS: 300})
+
+        self.min_pps_client.update_minimum_packet_rate_rule(
+            policy['id'], rule['id'],
+            **{qos_consts.MIN_KPPS: 350,
+               qos_consts.DIRECTION: n_constants.ANY_DIRECTION})
+
+        retrieved_rule = self.min_pps_client.show_minimum_packet_rate_rule(
+            policy['id'], rule['id'])[self.RULE_NAME]
+        self.assertEqual(350, retrieved_rule[qos_consts.MIN_KPPS])
+        self.assertEqual(n_constants.ANY_DIRECTION,
+                         retrieved_rule[qos_consts.DIRECTION])
+
+    @decorators.idempotent_id('a020e186-3d60-11ec-88ca-d7f5eec22764')
+    def test_rule_update_direction_conflict(self):
+        policy = self.create_qos_policy(name='test-policy',
+                                        description='test policy',
+                                        shared=False)
+        rule1 = self._create_qos_min_pps_rule(
+            policy['id'],
+            {qos_consts.DIRECTION: n_constants.EGRESS_DIRECTION,
+             qos_consts.MIN_KPPS: 300})
+
+        rule2 = self._create_qos_min_pps_rule(
+            policy['id'],
+            {qos_consts.DIRECTION: n_constants.INGRESS_DIRECTION,
+             qos_consts.MIN_KPPS: 300})
+
+        retrieved_rule1 = self.min_pps_client.show_minimum_packet_rate_rule(
+            policy['id'], rule1['id'])[self.RULE_NAME]
+        self.assertEqual(n_constants.EGRESS_DIRECTION,
+                         retrieved_rule1[qos_consts.DIRECTION])
+        retrieved_rule2 = self.min_pps_client.show_minimum_packet_rate_rule(
+            policy['id'], rule2['id'])[self.RULE_NAME]
+        self.assertEqual(n_constants.INGRESS_DIRECTION,
+                         retrieved_rule2[qos_consts.DIRECTION])
+
+        self.assertRaises(exceptions.Conflict,
+                          self.min_pps_client.update_minimum_packet_rate_rule,
+                          policy['id'], rule2['id'],
+                          **{qos_consts.DIRECTION: n_constants.ANY_DIRECTION})
+
+    @decorators.idempotent_id('c49018b6-d358-49a1-a94b-d53224165045')
+    def test_rule_delete(self):
+        policy = self.create_qos_policy(name='test-policy',
+                                        description='test policy',
+                                        shared=False)
+        rule = self._create_qos_min_pps_rule(
+            policy['id'],
+            {qos_consts.DIRECTION: n_constants.EGRESS_DIRECTION,
+             qos_consts.MIN_KPPS: 200})
+
+        retrieved_rule = self.min_pps_client.show_minimum_packet_rate_rule(
+            policy['id'], rule['id'])[self.RULE_NAME]
+        self.assertEqual(rule['id'], retrieved_rule['id'])
+
+        self.min_pps_client.delete_minimum_packet_rate_rule(policy['id'],
+                                                            rule['id'])
+        self.assertRaises(exceptions.NotFound,
+                          self.min_pps_client.show_minimum_packet_rate_rule,
+                          policy['id'], rule['id'])
+
+    @decorators.idempotent_id('1a6b6128-3d3e-11ec-bf49-57b326d417c0')
+    def test_rule_create_forbidden_for_regular_tenants(self):
+        self.assertRaises(
+            exceptions.Forbidden,
+            self.min_pps_client_primary.create_minimum_packet_rate_rule,
+            'policy', **{qos_consts.DIRECTION: n_constants.EGRESS_DIRECTION,
+                         qos_consts.MIN_KPPS: 300})
+
+    @decorators.idempotent_id('1b94f4e2-3d3e-11ec-bb21-6f98e4044b8b')
+    def test_get_rules_by_policy(self):
+        policy1 = self.create_qos_policy(name='test-policy1',
+                                         description='test policy1',
+                                         shared=False)
+        rule1 = self._create_qos_min_pps_rule(
+            policy1['id'],
+            {qos_consts.DIRECTION: n_constants.EGRESS_DIRECTION,
+             qos_consts.MIN_KPPS: 200})
+
+        policy2 = self.create_qos_policy(name='test-policy2',
+                                         description='test policy2',
+                                         shared=False)
+        rule2 = self._create_qos_min_pps_rule(
+            policy2['id'],
+            {qos_consts.DIRECTION: n_constants.EGRESS_DIRECTION,
+             qos_consts.MIN_KPPS: 5000})
+
+        # Test 'list rules'
+        rules = self.min_pps_client.list_minimum_packet_rate_rules(
+            policy1['id'])
+        rules = rules[self.RULES_NAME]
+        rules_ids = [r['id'] for r in rules]
+        self.assertIn(rule1['id'], rules_ids)
+        self.assertNotIn(rule2['id'], rules_ids)
+
+
 class QosSearchCriteriaTest(base.BaseSearchCriteriaTest,
                             base.BaseAdminNetworkTest):
 
diff --git a/neutron_tempest_plugin/api/test_qos_negative.py b/neutron_tempest_plugin/api/test_qos_negative.py
index 3e80129..505d1eb 100644
--- a/neutron_tempest_plugin/api/test_qos_negative.py
+++ b/neutron_tempest_plugin/api/test_qos_negative.py
@@ -11,7 +11,10 @@
 #    under the License.
 
 from neutron_lib.api.definitions import qos as qos_apidef
+from neutron_lib import constants as n_constants
 from neutron_lib.db import constants as db_const
+from neutron_lib.services.qos import constants as qos_consts
+from tempest.common import utils
 from tempest.lib.common.utils import data_utils
 from tempest.lib import decorators
 from tempest.lib import exceptions as lib_exc
@@ -103,6 +106,8 @@
         rule = self.rule_create_m(policy['id'], **create_params)
         if "minimum_bandwidth_rule" in rule.keys():
             rule_id = rule['minimum_bandwidth_rule']['id']
+        if "minimum_packet_rate_rule" in rule.keys():
+            rule_id = rule['minimum_packet_rate_rule']['id']
         if "bandwidth_limit_rule" in rule.keys():
             rule_id = rule['bandwidth_limit_rule']['id']
         if "dscp_mark" in rule.keys():
@@ -198,6 +203,41 @@
         self._test_rule_update_rule_nonexistent_rule(update_params)
 
 
+class QosMinimumPpsRuleNegativeTestJSON(QosRuleNegativeBaseTestJSON):
+
+    @classmethod
+    @utils.requires_ext(service='network',
+                        extension='port-resource-request-groups')
+    def resource_setup(cls):
+        cls.rule_create_m = cls.os_admin.qos_minimum_packet_rate_rules_client.\
+            create_minimum_packet_rate_rule
+        cls.rule_update_m = cls.os_admin.qos_minimum_packet_rate_rules_client.\
+            update_minimum_packet_rate_rule
+        super(QosMinimumPpsRuleNegativeTestJSON, cls).resource_setup()
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('ddd16824-3e10-11ec-928d-5b1ef3fb9f43')
+    def test_rule_update_rule_nonexistent_policy(self):
+        create_params = {qos_consts.DIRECTION: n_constants.EGRESS_DIRECTION,
+                         qos_consts.MIN_KPPS: 1}
+        update_params = {qos_consts.MIN_KPPS: 200}
+        self._test_rule_update_rule_nonexistent_policy(
+            create_params, update_params)
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('de4f5540-3e10-11ec-9700-4bf3629b843e')
+    def test_rule_create_rule_non_existent_policy(self):
+        create_params = {qos_consts.DIRECTION: n_constants.EGRESS_DIRECTION,
+                         qos_consts.MIN_KPPS: 200}
+        self._test_rule_create_rule_non_existent_policy(create_params)
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('deb914ee-3e10-11ec-b3dc-03e52f9269c9')
+    def test_rule_update_rule_nonexistent_rule(self):
+        update_params = {qos_consts.MIN_KPPS: 200}
+        self._test_rule_update_rule_nonexistent_rule(update_params)
+
+
 class QosDscpRuleNegativeTestJSON(QosRuleNegativeBaseTestJSON):
 
     @classmethod
diff --git a/neutron_tempest_plugin/services/network/json/network_client.py b/neutron_tempest_plugin/services/network/json/network_client.py
index a4c809e..e177e10 100644
--- a/neutron_tempest_plugin/services/network/json/network_client.py
+++ b/neutron_tempest_plugin/services/network/json/network_client.py
@@ -936,6 +936,92 @@
         self.expected_success(204, resp.status)
         service_client.ResponseBody(resp, body)
 
+    def create_local_ip(self, network_id, **kwargs):
+        post_body = {'local_ip': {
+            'network_id': network_id}}
+        if kwargs:
+            post_body['local_ip'].update(kwargs)
+        body = jsonutils.dumps(post_body)
+        uri = '%s/local_ips' % self.uri_prefix
+        resp, body = self.post(uri, body)
+        self.expected_success(201, resp.status)
+        body = jsonutils.loads(body)
+        return service_client.ResponseBody(resp, body)
+
+    def list_local_ips(self, **kwargs):
+        uri = '%s/local_ips' % self.uri_prefix
+        if kwargs:
+            uri += '?' + urlparse.urlencode(kwargs, doseq=1)
+        resp, body = self.get(uri)
+        self.expected_success(200, resp.status)
+        body = jsonutils.loads(body)
+        return service_client.ResponseBody(resp, body)
+
+    def get_local_ip(self, local_ip_id):
+        uri = '%s/local_ips/%s' % (self.uri_prefix, local_ip_id)
+        get_resp, get_resp_body = self.get(uri)
+        self.expected_success(200, get_resp.status)
+        body = jsonutils.loads(get_resp_body)
+        return service_client.ResponseBody(get_resp, body)
+
+    def update_local_ip(self, local_ip_id, **kwargs):
+        uri = '%s/local_ips/%s' % (self.uri_prefix, local_ip_id)
+        get_resp, _ = self.get(uri)
+        self.expected_success(200, get_resp.status)
+        put_body = jsonutils.dumps({'local_ip': kwargs})
+        put_resp, resp_body = self.put(uri, put_body)
+        self.expected_success(200, put_resp.status)
+        body = jsonutils.loads(resp_body)
+        return service_client.ResponseBody(put_resp, body)
+
+    def delete_local_ip(self, local_ip_id):
+        uri = '%s/local_ips/%s' % (
+            self.uri_prefix, local_ip_id)
+        resp, body = self.delete(uri)
+        self.expected_success(204, resp.status)
+        return service_client.ResponseBody(resp, body)
+
+    def create_local_ip_association(self, local_ip_id, fixed_port_id,
+                                    fixed_ip=None):
+        post_body = {'port_association': {
+            'fixed_port_id': fixed_port_id}}
+        if fixed_ip:
+            post_body['port_association']['fixed_ip'] = (
+                fixed_ip)
+        body = jsonutils.dumps(post_body)
+        uri = '%s/local_ips/%s/port_associations' % (self.uri_prefix,
+                                                     local_ip_id)
+        resp, body = self.post(uri, body)
+        self.expected_success(201, resp.status)
+        body = jsonutils.loads(body)
+        return service_client.ResponseBody(resp, body)
+
+    def get_local_ip_association(self, local_ip_id, fixed_port_id):
+        uri = '%s/local_ips/%s/port_associations/%s' % (self.uri_prefix,
+                                                        local_ip_id,
+                                                        fixed_port_id)
+        get_resp, get_resp_body = self.get(uri)
+        self.expected_success(200, get_resp.status)
+        body = jsonutils.loads(get_resp_body)
+        return service_client.ResponseBody(get_resp, body)
+
+    def list_local_ip_associations(self, local_ip_id):
+        uri = '%s/local_ips/%s/port_associations' % (self.uri_prefix,
+                                                     local_ip_id)
+        resp, body = self.get(uri)
+        self.expected_success(200, resp.status)
+        body = jsonutils.loads(body)
+        return service_client.ResponseBody(resp, body)
+
+    def delete_local_ip_association(self, local_ip_id, fixed_port_id):
+
+        uri = '%s/local_ips/%s/port_associations/%s' % (self.uri_prefix,
+                                                        local_ip_id,
+                                                        fixed_port_id)
+        resp, body = self.delete(uri)
+        self.expected_success(204, resp.status)
+        service_client.ResponseBody(resp, body)
+
     def create_conntrack_helper(self, router_id, helper, protocol, port):
         post_body = {'conntrack_helper': {
             'helper': helper,
diff --git a/requirements.txt b/requirements.txt
index 47dd923..21f14cc 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -11,7 +11,7 @@
 oslo.serialization!=2.19.1,>=2.18.0 # Apache-2.0
 oslo.utils>=3.33.0 # Apache-2.0
 paramiko>=2.0.0 # LGPLv2.1+
-tempest>=17.1.0 # Apache-2.0
+tempest>=29.2.0 # Apache-2.0
 tenacity>=3.2.1 # Apache-2.0
 ddt>=1.0.1 # MIT
 nose>=1.3.7 # LGPL
diff --git a/setup.cfg b/setup.cfg
index 136aa0f..3e352a0 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -18,6 +18,7 @@
     Programming Language :: Python :: 3.6
     Programming Language :: Python :: 3.7
     Programming Language :: Python :: 3.8
+    Programming Language :: Python :: 3.9
 
 [files]
 packages =
diff --git a/zuul.d/master_jobs.yaml b/zuul.d/master_jobs.yaml
index 90ba9a4..d24eae8 100644
--- a/zuul.d/master_jobs.yaml
+++ b/zuul.d/master_jobs.yaml
@@ -45,7 +45,9 @@
         - network_availability_zone
         - network-segment-range
         - pagination
+        - port-device-profile
         - port-resource-request
+        - port-resource-request-groups
         - port-mac-address-regenerate
         - port-security
         - port-security-groups-filtering
@@ -99,6 +101,7 @@
         OVN_BUILD_FROM_SOURCE: True
         OVN_BRANCH: "v21.03.0"
         OVS_BRANCH: "8dc1733eaea866dce033b3c44853e1b09bf59fc7"
+        NETWORK_API_EXTENSIONS: "{{ network_api_extensions_common | join(',') }}"
       devstack_local_conf:
         post-config:
           # NOTE(slaweq): We can get rid of this hardcoded absolute path when
@@ -132,7 +135,6 @@
       - ^zuul.d/(queens|rocky|stein|train|ussuri)_jobs.yaml$
       - ^zuul.d/base-nested-switch.yaml$
 
-
 - job:
     name: neutron-tempest-plugin-scenario-openvswitch
     parent: neutron-tempest-plugin-scenario-nested-switch
diff --git a/zuul.d/xena_jobs.yaml b/zuul.d/xena_jobs.yaml
index 7f6534d..5ad63f0 100644
--- a/zuul.d/xena_jobs.yaml
+++ b/zuul.d/xena_jobs.yaml
@@ -46,6 +46,7 @@
         - network_availability_zone
         - network-segment-range
         - pagination
+        - port-device-profile
         - port-resource-request
         - port-mac-address-regenerate
         - port-security