Merge "Add base API tests for port forwarding"
diff --git a/.zuul.yaml b/.zuul.yaml
index 6f26638..2406180 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -26,6 +26,7 @@
         - dns-domain-ports
         - dns-integration
         - empty-string-filtering
+        - expose-port-forwarding-in-fip
         - ext-gw-mode
         - external-net
         - extra_dhcp_opt
@@ -33,6 +34,7 @@
         - filter-validation
         - fip-port-details
         - flavors
+        - floating-ip-port-forwarding
         - floatingip-pools
         - ip-substring-filtering
         - l3-flavors
@@ -94,6 +96,7 @@
         neutron-trunk: true
         neutron-uplink-status-propagation: true
         neutron-network-segment-range: true
+        neutron-port-forwarding: true
       devstack_local_conf:
         post-config:
           $NEUTRON_CONF:
@@ -249,6 +252,7 @@
         - dns-domain-ports
         - dns-integration
         - empty-string-filtering
+        - expose-port-forwarding-in-fip
         - ext-gw-mode
         - external-net
         - extra_dhcp_opt
@@ -257,6 +261,7 @@
         - fip-port-details
         - flavors
         - floatingip-pools
+        - floating-ip-port-forwarding
         - ip-substring-filtering
         - l3-flavors
         - l3-ha
@@ -325,12 +330,14 @@
         - dns-domain-ports
         - dns-integration
         - empty-string-filtering
+        - expose-port-forwarding-in-fip
         - ext-gw-mode
         - external-net
         - extra_dhcp_opt
         - extraroute
         - fip-port-details
         - flavors
+        - floating-ip-port-forwarding
         - ip-substring-filtering
         - l3-flavors
         - l3-ha
diff --git a/neutron_tempest_plugin/api/base.py b/neutron_tempest_plugin/api/base.py
index 2c062c4..79ac4a6 100644
--- a/neutron_tempest_plugin/api/base.py
+++ b/neutron_tempest_plugin/api/base.py
@@ -117,6 +117,7 @@
         cls.ports = []
         cls.routers = []
         cls.floating_ips = []
+        cls.port_forwardings = []
         cls.metering_labels = []
         cls.service_profiles = []
         cls.flavors = []
@@ -144,6 +145,10 @@
             for trunk in cls.trunks:
                 cls._try_delete_resource(cls.delete_trunk, trunk)
 
+            # Clean up port forwardings
+            for pf in cls.port_forwardings:
+                cls._try_delete_resource(cls.delete_port_forwarding, pf)
+
             # Clean up floating IPs
             for floating_ip in cls.floating_ips:
                 cls._try_delete_resource(cls.delete_floatingip, floating_ip)
@@ -652,6 +657,66 @@
         client.delete_floatingip(floating_ip['id'])
 
     @classmethod
+    def create_port_forwarding(cls, fip_id, internal_port_id,
+                               internal_port, external_port,
+                               internal_ip_address=None, protocol="tcp",
+                               client=None):
+        """Creates a port forwarding.
+
+        Create a port forwarding and schedule it for later deletion.
+        If a client is passed, then it is used for deleting the PF too.
+
+        :param fip_id: The ID of the floating IP address.
+
+        :param internal_port_id: The ID of the Neutron port associated to
+        the floating IP port forwarding.
+
+        :param internal_port: The TCP/UDP/other protocol port number of the
+        Neutron port fixed IP address associated to the floating ip
+        port forwarding.
+
+        :param external_port: The TCP/UDP/other protocol port number of
+        the port forwarding floating IP address.
+
+        :param internal_ip_address: The fixed IPv4 address of the Neutron
+        port associated to the floating IP port forwarding.
+
+        :param protocol: The IP protocol used in the floating IP port
+        forwarding.
+
+        :param client: network client to be used for creating and cleaning up
+        the floating IP port forwarding.
+        """
+
+        client = client or cls.client
+
+        pf = client.create_port_forwarding(
+            fip_id, internal_port_id, internal_port, external_port,
+            internal_ip_address, protocol)['port_forwarding']
+
+        # save ID of floating IP associated with port forwarding for final
+        # cleanup
+        pf['floatingip_id'] = fip_id
+
+        # save client to be used later in cls.delete_port_forwarding
+        # for final cleanup
+        pf['client'] = client
+        cls.port_forwardings.append(pf)
+        return pf
+
+    @classmethod
+    def delete_port_forwarding(cls, pf, client=None):
+        """Delete port forwarding
+
+        :param client: Client to be used
+        If client is not given it will use the client used to create
+        the port forwarding, or cls.client if unknown.
+        """
+
+        client = client or pf.get('client') or cls.client
+        client.delete_port_forwarding(pf['floatingip_id'], pf['id'])
+
+    @classmethod
     def create_router_interface(cls, router_id, subnet_id):
         """Wrapper utility that returns a router interface."""
         interface = cls.client.add_router_interface_with_subnet_id(
diff --git a/neutron_tempest_plugin/api/test_port_forwardings.py b/neutron_tempest_plugin/api/test_port_forwardings.py
new file mode 100644
index 0000000..5abc8bb
--- /dev/null
+++ b/neutron_tempest_plugin/api/test_port_forwardings.py
@@ -0,0 +1,172 @@
+# Copyright 2019 Red Hat, 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.common import utils
+from tempest.lib.common.utils import data_utils
+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 PortForwardingTestJSON(base.BaseNetworkTest):
+
+    required_extensions = ['router', 'floating-ip-port-forwarding']
+
+    @classmethod
+    def resource_setup(cls):
+        super(PortForwardingTestJSON, cls).resource_setup()
+        cls.ext_net_id = CONF.network.public_network_id
+
+        # Create network, subnet, router and add interface
+        cls.network = cls.create_network()
+        cls.subnet = cls.create_subnet(cls.network)
+        cls.router = cls.create_router(data_utils.rand_name('router'),
+                                       external_network_id=cls.ext_net_id)
+        cls.create_router_interface(cls.router['id'], cls.subnet['id'])
+
+    @decorators.idempotent_id('829a446e-46bc-41ce-b442-6e428aeb3c19')
+    def test_port_forwarding_life_cycle(self):
+        fip = self.create_floatingip()
+        port = self.create_port(self.network)
+        # Create port forwarding for one TCP port
+        created_pf = self.create_port_forwarding(
+            fip['id'],
+            internal_port_id=port['id'],
+            internal_ip_address=port['fixed_ips'][0]['ip_address'],
+            internal_port=1111, external_port=2222, protocol="tcp")
+        self.assertEqual(1111, created_pf['internal_port'])
+        self.assertEqual(2222, created_pf['external_port'])
+        self.assertEqual('tcp', created_pf['protocol'])
+        self.assertEqual(port['fixed_ips'][0]['ip_address'],
+                         created_pf['internal_ip_address'])
+
+        # Show created port forwarding
+        body = self.client.get_port_forwarding(
+            fip['id'], created_pf['id'])
+        pf = body['port_forwarding']
+        self.assertEqual(1111, pf['internal_port'])
+        self.assertEqual(2222, pf['external_port'])
+        self.assertEqual('tcp', pf['protocol'])
+        self.assertEqual(port['fixed_ips'][0]['ip_address'],
+                         pf['internal_ip_address'])
+
+        # Update port forwarding
+        body = self.client.update_port_forwarding(
+            fip['id'], pf['id'], internal_port=3333)
+        pf = body['port_forwarding']
+        self.assertEqual(3333, pf['internal_port'])
+        self.assertEqual(2222, pf['external_port'])
+        self.assertEqual('tcp', pf['protocol'])
+        self.assertEqual(port['fixed_ips'][0]['ip_address'],
+                         pf['internal_ip_address'])
+
+        # Delete port forwarding
+        self.client.delete_port_forwarding(fip['id'], pf['id'])
+        self.assertRaises(exceptions.NotFound,
+                          self.client.get_port_forwarding,
+                          fip['id'], pf['id'])
+
+    @decorators.idempotent_id('aa842070-39ef-4b09-9df9-e723934f96f8')
+    @utils.requires_ext(extension="expose-port-forwarding-in-fip",
+                        service="network")
+    def test_port_forwarding_info_in_fip_details(self):
+        fip = self.create_floatingip()
+        port = self.create_port(self.network)
+
+        # Ensure that FIP don't have information about any port forwarding yet
+        fip = self.client.show_floatingip(fip['id'])['floatingip']
+        self.assertEqual(0, len(fip['port_forwardings']))
+
+        # Now create port forwarding and ensure that it is visible in FIP's
+        # details
+        pf = self.create_port_forwarding(
+            fip['id'],
+            internal_port_id=port['id'],
+            internal_ip_address=port['fixed_ips'][0]['ip_address'],
+            internal_port=1111, external_port=2222, protocol="tcp")
+        fip = self.client.show_floatingip(fip['id'])['floatingip']
+        self.assertEqual(1, len(fip['port_forwardings']))
+        self.assertEqual(1111, fip['port_forwardings'][0]['internal_port'])
+        self.assertEqual(2222, fip['port_forwardings'][0]['external_port'])
+        self.assertEqual('tcp', fip['port_forwardings'][0]['protocol'])
+        self.assertEqual(port['fixed_ips'][0]['ip_address'],
+                         fip['port_forwardings'][0]['internal_ip_address'])
+
+        # Delete port forwarding and ensure that it's not in FIP's details
+        # anymore
+        self.client.delete_port_forwarding(fip['id'], pf['id'])
+        fip = self.client.show_floatingip(fip['id'])['floatingip']
+        self.assertEqual(0, len(fip['port_forwardings']))
+
+    @decorators.idempotent_id('8202cded-7e82-4420-9585-c091105404f6')
+    def test_associate_2_port_forwardings_to_floating_ip(self):
+        fip = self.create_floatingip()
+        forwardings_data = [(1111, 2222), (3333, 4444)]
+        created_pfs = []
+        for data in forwardings_data:
+            internal_port = data[0]
+            external_port = data[1]
+            port = self.create_port(self.network)
+            created_pf = self.create_port_forwarding(
+                fip['id'],
+                internal_port_id=port['id'],
+                internal_ip_address=port['fixed_ips'][0]['ip_address'],
+                internal_port=internal_port, external_port=external_port,
+                protocol="tcp")
+            self.assertEqual(internal_port, created_pf['internal_port'])
+            self.assertEqual(external_port, created_pf['external_port'])
+            self.assertEqual('tcp', created_pf['protocol'])
+            self.assertEqual(port['fixed_ips'][0]['ip_address'],
+                             created_pf['internal_ip_address'])
+            created_pfs.append(created_pf)
+
+        # Check that all PFs are visible in Floating IP details
+        fip = self.client.show_floatingip(fip['id'])['floatingip']
+        self.assertEqual(len(forwardings_data), len(fip['port_forwardings']))
+        for pf in created_pfs:
+            expected_pf = {
+                'external_port': pf['external_port'],
+                'internal_port': pf['internal_port'],
+                'protocol': pf['protocol'],
+                'internal_ip_address': pf['internal_ip_address']}
+            self.assertIn(expected_pf, fip['port_forwardings'])
+
+        # Test list of port forwardings
+        port_forwardings = self.client.list_port_forwardings(
+            fip['id'])['port_forwardings']
+        self.assertEqual(len(forwardings_data), len(port_forwardings))
+        for pf in created_pfs:
+            expected_pf = pf.copy()
+            expected_pf.pop('client')
+            expected_pf.pop('floatingip_id')
+            self.assertIn(expected_pf, port_forwardings)
+
+    @decorators.idempotent_id('6a34e811-66d1-4f63-aa4d-9013f15deb62')
+    def test_associate_port_forwarding_to_used_floating_ip(self):
+        port_for_fip = self.create_port(self.network)
+        fip = self.create_floatingip(port=port_for_fip)
+        port = self.create_port(self.network)
+        self.assertRaises(
+            exceptions.Conflict,
+            self.create_port_forwarding,
+            fip['id'],
+            internal_port_id=port['id'],
+            internal_ip_address=port['fixed_ips'][0]['ip_address'],
+            internal_port=1111, external_port=2222,
+            protocol="tcp")
diff --git a/neutron_tempest_plugin/services/network/json/network_client.py b/neutron_tempest_plugin/services/network/json/network_client.py
index 25fc8c1..422b071 100644
--- a/neutron_tempest_plugin/services/network/json/network_client.py
+++ b/neutron_tempest_plugin/services/network/json/network_client.py
@@ -938,6 +938,55 @@
         body = jsonutils.loads(resp_body)
         return service_client.ResponseBody(put_resp, body)
 
+    def create_port_forwarding(self, fip_id, internal_port_id,
+                               internal_port, external_port,
+                               internal_ip_address=None, protocol='tcp'):
+        post_body = {'port_forwarding': {
+            'protocol': protocol,
+            'internal_port_id': internal_port_id,
+            'internal_port': int(internal_port),
+            'external_port': int(external_port)}}
+        if internal_ip_address:
+            post_body['port_forwarding']['internal_ip_address'] = (
+                internal_ip_address)
+        body = jsonutils.dumps(post_body)
+        uri = '%s/floatingips/%s/port_forwardings' % (self.uri_prefix, fip_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_port_forwarding(self, fip_id, pf_id):
+        uri = '%s/floatingips/%s/port_forwardings/%s' % (self.uri_prefix,
+                                                         fip_id, pf_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_port_forwardings(self, fip_id):
+        uri = '%s/floatingips/%s/port_forwardings' % (self.uri_prefix, fip_id)
+        resp, body = self.get(uri)
+        self.expected_success(200, resp.status)
+        body = jsonutils.loads(body)
+        return service_client.ResponseBody(resp, body)
+
+    def update_port_forwarding(self, fip_id, pf_id, **kwargs):
+        uri = '%s/floatingips/%s/port_forwardings/%s' % (self.uri_prefix,
+                                                         fip_id, pf_id)
+        put_body = jsonutils.dumps({'port_forwarding': 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_port_forwarding(self, fip_id, pf_id):
+        uri = '%s/floatingips/%s/port_forwardings/%s' % (self.uri_prefix,
+                                                         fip_id, pf_id)
+        resp, body = self.delete(uri)
+        self.expected_success(204, resp.status)
+        service_client.ResponseBody(resp, body)
+
     def create_network_keystone_v3(self, name, project_id, tenant_id=None):
         uri = '%s/networks' % self.uri_prefix
         post_data = {