diff --git a/openstack_dashboard/api/neutron.py b/openstack_dashboard/api/neutron.py index dcc9ac78b7..3df0e8a8ca 100644 --- a/openstack_dashboard/api/neutron.py +++ b/openstack_dashboard/api/neutron.py @@ -510,15 +510,38 @@ class SecurityGroupManager(object): class FloatingIp(base.APIDictWrapper): _attrs = ['id', 'ip', 'fixed_ip', 'port_id', 'instance_id', - 'instance_type', 'pool', 'dns_domain', 'dns_name'] + 'instance_type', 'pool', 'dns_domain', 'dns_name', + 'port_forwardings'] def __init__(self, fip): fip['ip'] = fip['floating_ip_address'] fip['fixed_ip'] = fip['fixed_ip_address'] fip['pool'] = fip['floating_network_id'] + fip['port_forwardings'] = fip.get('portforwardings', {}) super().__init__(fip) +class PortForwarding(base.APIDictWrapper): + _attrs = ['id', 'floating_ip_id', 'protocol', 'internal_port_range', + 'external_port_range', 'internal_ip_address', + 'description', 'internal_port_id', 'external_ip_address'] + + def __init__(self, pfw, fip): + pfw['floating_ip_id'] = fip + port_forwarding = pfw + if 'port_forwarding' in pfw: + port_forwarding = pfw['port_forwarding'] + port_forwarding['internal_port_range'] = ':'.join( + map(str, sorted( + map(int, set(port_forwarding.get( + 'internal_port_range', '').split(':')))))) + port_forwarding['external_port_range'] = ':'.join( + map(str, sorted( + map(int, set(port_forwarding.get( + 'external_port_range', '').split(':')))))) + super().__init__(pfw) + + class FloatingIpPool(base.APIDictWrapper): pass @@ -544,6 +567,81 @@ class FloatingIpTarget(base.APIDictWrapper): super().__init__(target) +class PortForwardingManager(object): + + def __init__(self, request): + self.request = request + self.client = neutronclient(request) + + @profiler.trace + def list(self, floating_ip_id, **search_opts): + port_forwarding_rules = self.client.list_port_forwardings( + floating_ip_id, **search_opts) + port_forwarding_rules = port_forwarding_rules.get('port_forwardings') + LOG.debug("Portforwarding rules listed=%s", port_forwarding_rules) + return [PortForwarding(port_forwarding_rule, floating_ip_id) + for port_forwarding_rule in port_forwarding_rules] + + @profiler.trace + def update(self, floating_ip_id, **params): + portforwarding_dict = self.create_port_forwarding_dict(**params) + portforwarding_id = params['portforwarding_id'] + LOG.debug("Updating Portforwarding rule with id %s", portforwarding_id) + pfw = self.client.update_port_forwarding( + floating_ip_id, + portforwarding_id, + {'port_forwarding': portforwarding_dict}).get('port_forwarding') + + return PortForwarding(pfw, floating_ip_id) + + @profiler.trace + def create(self, floating_ip_id, **params): + portforwarding_dict = self.create_port_forwarding_dict(**params) + portforwarding_rule = self.client.create_port_forwarding( + floating_ip_id, + {'port_forwarding': portforwarding_dict}).get('port_forwarding') + LOG.debug("Created a Portforwarding rule to floating IP %s with id %s", + floating_ip_id, + portforwarding_rule['id']) + return PortForwarding(portforwarding_rule, floating_ip_id) + + def create_port_forwarding_dict(self, **params): + portforwarding_dict = {} + if 'protocol' in params: + portforwarding_dict['protocol'] = str(params['protocol']).lower() + if 'internal_port' in params: + internal_port = str(params['internal_port']) + if ':' not in internal_port: + portforwarding_dict['internal_port'] = int(internal_port) + else: + portforwarding_dict['internal_port_range'] = internal_port + if 'external_port' in params: + external_port = str(params['external_port']) + if ':' not in external_port: + portforwarding_dict['external_port'] = int(external_port) + else: + portforwarding_dict['external_port_range'] = external_port + if 'internal_ip_address' in params: + portforwarding_dict['internal_ip_address'] = params[ + 'internal_ip_address'] + if 'description' in params: + portforwarding_dict['description'] = params['description'] + if 'internal_port_id' in params: + portforwarding_dict['internal_port_id'] = params['internal_port_id'] + return portforwarding_dict + + def delete(self, floating_ip_id, portforwarding_id): + self.client.delete_port_forwarding(floating_ip_id, portforwarding_id) + LOG.debug( + "The Portforwarding rule of floating IP %s with id %s was deleted", + floating_ip_id, portforwarding_id) + + def get(self, floating_ip_id, portforwarding_id): + pfw = self.client.show_port_forwarding(floating_ip_id, + portforwarding_id) + return PortForwarding(pfw, portforwarding_id) + + class FloatingIpManager(object): """Manager class to implement Floating IP methods @@ -1956,6 +2054,26 @@ def tenant_floating_ip_list(request, all_tenants=False, **search_opts): **search_opts) +def floating_ip_port_forwarding_list(request, fip): + return PortForwardingManager(request).list(fip) + + +def floating_ip_port_forwarding_create(request, fip, **params): + return PortForwardingManager(request).create(fip, **params) + + +def floating_ip_port_forwarding_update(request, fip, **params): + return PortForwardingManager(request).update(fip, **params) + + +def floating_ip_port_forwarding_get(request, fip, pfw): + return PortForwardingManager(request).get(fip, pfw) + + +def floating_ip_port_forwarding_delete(request, fip, pfw): + return PortForwardingManager(request).delete(fip, pfw) + + def tenant_floating_ip_get(request, floating_ip_id): return FloatingIpManager(request).get(floating_ip_id) @@ -2179,6 +2297,18 @@ def is_extension_supported(request, extension_alias): return False +@profiler.trace +def is_extension_floating_ip_port_forwarding_supported(request): + try: + return is_extension_supported( + request, extension_alias='floating-ip-port-forwarding') + except Exception as e: + LOG.error("It was not possible to check if the " + "floating-ip-port-forwarding extension is enabled in " + "neutron. Port forwardings will not be enabled.: %s", e) + return False + + # TODO(amotoki): Clean up 'default' parameter because the default # values are pre-defined now, so 'default' argument is meaningless # in most cases. diff --git a/openstack_dashboard/dashboards/project/floating_ip_portforwardings/__init__.py b/openstack_dashboard/dashboards/project/floating_ip_portforwardings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/dashboards/project/floating_ip_portforwardings/panel.py b/openstack_dashboard/dashboards/project/floating_ip_portforwardings/panel.py new file mode 100644 index 0000000000..08c430fa6d --- /dev/null +++ b/openstack_dashboard/dashboards/project/floating_ip_portforwardings/panel.py @@ -0,0 +1,37 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import logging + +from django.utils.translation import gettext_lazy as _ + +import horizon + +from openstack_dashboard.api import neutron + +LOG = logging.getLogger(__name__) + + +class FloatingIpPortforwardingRules(horizon.Panel): + name = _("Floating IP port forwarding rules") + slug = 'floating_ip_portforwardings' + permissions = ('openstack.services.network',) + nav = False + + def allowed(self, context): + request = context['request'] + return ( + super().allowed(context) and + request.user.has_perms(self.permissions) and + neutron.is_extension_floating_ip_port_forwarding_supported( + request) + ) diff --git a/openstack_dashboard/dashboards/project/floating_ip_portforwardings/tables.py b/openstack_dashboard/dashboards/project/floating_ip_portforwardings/tables.py new file mode 100644 index 0000000000..b2f5bed14f --- /dev/null +++ b/openstack_dashboard/dashboards/project/floating_ip_portforwardings/tables.py @@ -0,0 +1,194 @@ +# 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 django import shortcuts +from django.urls import reverse +from django.utils.http import urlencode +from django.utils.translation import gettext_lazy as _ +from django.utils.translation import ngettext_lazy + +from horizon import tables + +from openstack_dashboard import api +from openstack_dashboard import policy + +PROTOCOL_CHOICES = ( + ("Select a protocol", "Select a protocol"), + ("UDP", "UDP"), + ("TCP", "TCP"), +) + + +class CreateFloatingIpPortForwardingRule(tables.LinkAction): + name = "create" + verbose_name = _("Add floating IP port forwarding rule") + classes = ("ajax-modal",) + icon = "plus" + url = "horizon:project:floating_ip_portforwardings:create" + floating_ip_id = None + + def allowed(self, request, fip=None): + policy_rules = (("network", "create_floatingip_port_forwarding"),) + return policy.check(policy_rules, request) + + def single(self, data_table, request, *args): + return shortcuts.redirect( + 'horizon:project:floating_ip_portforwardings:show') + + def get_url_params(self, datum=None): + return urlencode({"floating_ip_id": self.floating_ip_id}) + + def get_link_url(self, datum=None): + base_url = reverse(self.url) + join = "?".join([base_url, self.get_url_params(datum)]) + return join + + +class EditFloatingIpPortForwardingRule(CreateFloatingIpPortForwardingRule): + name = "edit" + verbose_name = _("Edit floating IP port forwarding rule") + classes = ("ajax-modal", "btn-edit") + url = "horizon:project:floating_ip_portforwardings:edit" + + def allowed(self, request, fip=None): + policy_rules = (("network", "update_floatingip_port_forwarding"),) + return policy.check(policy_rules, request) + + def get_url_params(self, datum=None): + portforwading_id = self.table.get_object_id(datum) + return urlencode({"floating_ip_id": self.floating_ip_id, + "pfwd_id": portforwading_id}) + + +class EditFloatingIpPortForwardingRuleFromAllPanel( + EditFloatingIpPortForwardingRule): + name = "edit-from-all" + url = "horizon:project:floating_ip_portforwardings:editToAll" + + def single(self, data_table, request, *args): + return shortcuts.redirect( + 'horizon:project:floating_ip_portforwardings:index') + + def get_url_params(self, datum=None): + portforwading_id = self.table.get_object_id(datum) + return urlencode({"floating_ip_id": datum.floating_ip_id, + "pfwd_id": portforwading_id}) + + +class DeleteRule(tables.DeleteAction): + name = "delete" + help_text = _( + "This action will delete the " + "selected floating IP port forwarding rule(s); " + "this process cannot be undone.") + floating_ip_id = None + + @staticmethod + def action_present(count): + return ngettext_lazy( + u"Delete Rule", + u"Delete Rules", + count + ) + + @staticmethod + def action_past(count): + return ngettext_lazy( + u"Deleted Rule", + u"Deleted Rules", + count + ) + + def allowed(self, request, fip=None): + policy_rules = (("network", "delete_floatingip_port_forwarding"),) + return policy.check(policy_rules, request) + + def action(self, request, obj_id): + api.neutron.floating_ip_port_forwarding_delete(request, + self.floating_ip_id, + obj_id) + + +class DeleteRuleFromAllPanel(DeleteRule): + name = "delete-from-all" + + def action(self, request, obj_id): + datum = self.table.get_object_by_id(obj_id) + api.neutron.floating_ip_port_forwarding_delete(request, + datum.floating_ip_id, + obj_id) + + +class FloatingIpPortForwardingRulesTable(tables.DataTable): + protocol = tables.Column("protocol", verbose_name=_("Protocol")) + external_port_range = tables.Column("external_port_range", + verbose_name=_("External port")) + internal_port_range = tables.Column("internal_port_range", + verbose_name=_("Internal port")) + internal_ip_address = tables.Column("internal_ip_address", + verbose_name=_("Internal IP address")) + description = tables.Column("description", verbose_name=_("Description")) + + def __init__(self, request, data=None, needs_form_wrapper=None, **kwargs): + super().__init__( + request, data=data, needs_form_wrapper=needs_form_wrapper, + **kwargs) + + floating_ip_id = request.GET.get('floating_ip_id') + + for action in self.get_table_actions(): + action.floating_ip_id = floating_ip_id + + for action in self._meta.row_actions: + action.floating_ip_id = floating_ip_id + + def get_object_display(self, datum): + return str(datum.internal_ip_address) + ':' + str( + datum.internal_port_range) + + class Meta(object): + name = "floating_ip_portforwardings" + verbose_name = _("Floating IP port forwarding rules") + table_actions = (CreateFloatingIpPortForwardingRule, DeleteRule) + row_actions = (EditFloatingIpPortForwardingRule, DeleteRule) + + +class AllFloatingIpPortForwardingRulesTable(tables.DataTable): + floating_ip_id = tables.Column("floating_ip_id", + verbose_name=_("floating_ip_id"), + hidden=True) + protocol = tables.Column("protocol", verbose_name=_("Protocol")) + external_port_range = tables.Column("external_port_range", + verbose_name=_("External port")) + internal_port_range = tables.Column("internal_port_range", + verbose_name=_("Internal port")) + external_ip_address = tables.Column("external_ip_address", + verbose_name=_("External IP address")) + internal_ip_address = tables.Column("internal_ip_address", + verbose_name=_("Internal IP address")) + description = tables.Column("description", verbose_name=_("Description")) + + def __init__(self, request, data=None, needs_form_wrapper=None, **kwargs): + super().__init__( + request, data=data, needs_form_wrapper=needs_form_wrapper, **kwargs) + + def get_object_display(self, datum): + return str(datum.internal_ip_address) + ':' + str( + datum.internal_port_range) + + class Meta(object): + name = "floating_ip_portforwardings" + verbose_name = _("Floating IP port forwarding rules") + table_actions = (DeleteRuleFromAllPanel,) + row_actions = ( + EditFloatingIpPortForwardingRuleFromAllPanel, + DeleteRuleFromAllPanel) diff --git a/openstack_dashboard/dashboards/project/floating_ip_portforwardings/tests.py b/openstack_dashboard/dashboards/project/floating_ip_portforwardings/tests.py new file mode 100644 index 0000000000..f791b428f1 --- /dev/null +++ b/openstack_dashboard/dashboards/project/floating_ip_portforwardings/tests.py @@ -0,0 +1,262 @@ +# 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 unittest import mock +import uuid + +from django.urls import reverse +from django.utils.http import urlencode + +from openstack_dashboard import api +from openstack_dashboard.test import helpers as test + +from horizon.tables import views as table_views +from horizon.workflows import views + +INDEX_URL = reverse('horizon:project:floating_ip_portforwardings:index') +NAMESPACE = "horizon:project:floating_ip_portforwardings" + + +class FloatingIpPortforwardingViewTests(test.TestCase): + + def setUp(self): + super().setUp() + api_mock = mock.patch.object( + api.neutron, + 'is_extension_floating_ip_port_forwarding_supported').start() + api_mock.return_value = True + + @test.create_mocks({api.neutron: ('tenant_floating_ip_get', + 'floating_ip_port_forwarding_list')}) + def test_floating_ip_portforwarding(self): + fip = self._get_fip_targets()[0] + self.mock_tenant_floating_ip_get.return_value = fip + fip_id = fip.id + self.mock_floating_ip_port_forwarding_list.return_value = ( + fip.port_forwardings) + + params = urlencode({'floating_ip_id': fip_id}) + url = '?'.join([reverse('%s:show' % NAMESPACE), params]) + res = self.client.get(url) + self.assertTemplateUsed(res, table_views.DataTableView.template_name) + table_data = res.context_data['table'].data + self.assertEqual(len(table_data), 1) + self.assertEqual(fip.port_forwardings[0].id, table_data[0].id) + + @test.create_mocks({api.neutron: ('floating_ip_port_forwarding_list', + 'tenant_floating_ip_list')}) + def test_floating_ip_portforwarding_all(self): + fips = self._get_fip_targets() + self.mock_tenant_floating_ip_list.return_value = fips + fips_dict = {} + for f in fips: + fips_dict[f.id] = f.port_forwardings + + def pfw_list(request, fip_id): + return fips_dict[fip_id] + + self.mock_floating_ip_port_forwarding_list.side_effect = pfw_list + + url = reverse('%s:index' % NAMESPACE) + + res = self.client.get(url) + self.assertTemplateUsed(res, table_views.DataTableView.template_name) + table_data = res.context_data['table'].data + self.assertEqual(len(table_data), len(fips)) + for pfw in table_data: + self.assertIn(pfw.id, list(map(lambda x: x.id, + fips_dict[pfw.floating_ip_id]))) + + def _get_compute_ports(self): + return [p for p in self.ports.list() + if not p.device_owner.startswith('network:')] + + def _get_fip_targets(self): + server_dict = dict((s.id, s.name) for s in self.servers.list()) + targets = [] + port = 10 + for p in self._get_compute_ports(): + for ip in p.fixed_ips: + targets.append(api.neutron.FloatingIpTarget( + p, ip['ip_address'], server_dict.get(p.device_id))) + targets[-1].ip = ip['ip_address'] + targets[-1].port_id = None + targets[-1].port_forwardings = [api.neutron.PortForwarding({ + 'id': str(uuid.uuid4()), + 'floating_ip_id': targets[-1].id, + 'protocol': 'TCP', + 'internal_port_range': str(port), + 'external_port_range': str(port + 10), + 'internal_ip_address': ip['ip_address'], + 'description': '', + 'internal_port_id': '', + 'external_ip_address': ''}, targets[-1].id)] + + port += 1 + return targets + + @test.create_mocks({api.neutron: ('tenant_floating_ip_get', + 'floating_ip_port_forwarding_list', + 'floating_ip_target_list')}) + def test_create_floating_ip_portforwarding(self): + fip = self._get_fip_targets()[0] + self.mock_tenant_floating_ip_get.return_value = fip + fip_id = fip.id + self.mock_floating_ip_port_forwarding_list.return_value = ( + fip.port_forwardings) + self.mock_floating_ip_target_list.return_value = [fip] + + params = urlencode({'floating_ip_id': fip_id}) + url = '?'.join([reverse('%s:create' % NAMESPACE), params]) + res = self.client.get(url) + self.assertTemplateUsed(res, views.WorkflowView.template_name) + workflow = res.context['workflow'] + choices = dict( + workflow.steps[0].action.fields[ + 'internal_ip_address'].choices) + choices.pop('Select an IP-Address') + + self.assertEqual({fip.id}, set(choices.keys())) + + @test.create_mocks({api.neutron: ('tenant_floating_ip_get', + 'floating_ip_port_forwarding_list', + 'floating_ip_port_forwarding_create', + 'floating_ip_target_list')}) + def test_create_floating_ip_portforwarding_post(self): + fip = self._get_fip_targets()[0] + self.mock_tenant_floating_ip_get.return_value = fip + fip_id = fip.id + self.mock_floating_ip_port_forwarding_list.return_value = ( + fip.port_forwardings) + self.mock_floating_ip_target_list.return_value = [fip] + + create_mock = self.mock_floating_ip_port_forwarding_create + + params = urlencode({'floating_ip_id': fip_id}) + url = '?'.join([reverse('%s:create' % NAMESPACE), params]) + port = self.ports.get(id=fip.id.split('_')[0]) + internal_ip = '%s_%s' % (port.id, port.fixed_ips[0]['ip_address']) + post_params = { + 'floating_ip_id': fip_id, + 'description': 'test', + 'internal_port': '10', + 'protocol': 'TCP', + 'internal_ip_address': internal_ip, + 'external_port': '123', + } + expected_params = { + 'description': 'test', + 'internal_port': '10', + 'protocol': 'TCP', + 'internal_port_id': internal_ip.split('_')[0], + 'internal_ip_address': internal_ip.split('_')[1], + 'external_port': '123', + } + self.client.post(url, post_params) + create_mock.assert_called_once_with(mock.ANY, fip_id, + **expected_params) + + @test.create_mocks({api.neutron: ('tenant_floating_ip_get', + 'floating_ip_port_forwarding_list', + 'floating_ip_target_list', + 'floating_ip_port_forwarding_get')}) + def test_update_floating_ip_portforwarding(self): + fip = self._get_fip_targets()[0] + self.mock_tenant_floating_ip_get.return_value = fip + fip_id = fip.id + self.mock_floating_ip_port_forwarding_list.return_value = ( + fip.port_forwardings) + self.mock_floating_ip_target_list.return_value = [fip] + self.mock_floating_ip_port_forwarding_get.return_value = { + 'port_forwarding': fip.port_forwardings[0].to_dict() + } + + params = urlencode({'floating_ip_id': fip_id, + 'pfwd_id': fip.port_forwardings[0]['id']}) + url = '?'.join([reverse('%s:edit' % NAMESPACE), params]) + res = self.client.get(url) + self.assertTemplateUsed(res, views.WorkflowView.template_name) + workflow = res.context['workflow'] + + self.assertEqual(workflow.steps[0].action.initial['floating_ip_id'], + fip.port_forwardings[0]['floating_ip_id']) + self.assertEqual(workflow.steps[0].action.initial['portforwading_id'], + fip.port_forwardings[0]['id']) + self.assertEqual(workflow.steps[0].action.initial['protocol'], + fip.port_forwardings[0]['protocol']) + self.assertEqual(workflow.steps[0].action.initial['internal_port'], + fip.port_forwardings[0]['internal_port_range']) + self.assertEqual(workflow.steps[0].action.initial['external_port'], + fip.port_forwardings[0]['external_port_range']) + self.assertEqual(workflow.steps[0].action.initial['description'], + fip.port_forwardings[0]['description']) + + @test.create_mocks({api.neutron: ('tenant_floating_ip_get', + 'floating_ip_port_forwarding_list', + 'floating_ip_target_list', + 'floating_ip_port_forwarding_update', + 'floating_ip_port_forwarding_get')}) + def test_update_floating_ip_portforwarding_post(self): + fip = self._get_fip_targets()[0] + self.mock_tenant_floating_ip_get.return_value = fip + fip_id = fip.id + self.mock_floating_ip_port_forwarding_list.return_value = ( + fip.port_forwardings) + self.mock_floating_ip_target_list.return_value = [fip] + self.mock_floating_ip_port_forwarding_get.return_value = { + 'port_forwarding': fip.port_forwardings[0].to_dict() + } + update_mock = self.mock_floating_ip_port_forwarding_update + pfw_id = fip.port_forwardings[0]['id'] + params = urlencode({'floating_ip_id': fip_id, + 'pfwd_id': pfw_id}) + url = '?'.join([reverse('%s:edit' % NAMESPACE), params]) + port = self.ports.get(id=fip.id.split('_')[0]) + internal_ip = '%s_%s' % (port.id, port.fixed_ips[0]['ip_address']) + + post_params = { + 'portforwading_id': pfw_id, + 'floating_ip_id': fip_id, + 'description': 'test', + 'internal_port': '10', + 'protocol': 'TCP', + 'internal_ip_address': internal_ip, + 'external_port': '123', + } + expected_params = { + 'portforwarding_id': pfw_id, + 'description': 'test', + 'internal_port': '10', + 'protocol': 'TCP', + 'internal_port_id': internal_ip.split('_')[0], + 'internal_ip_address': internal_ip.split('_')[1], + 'external_port': '123', + } + self.client.post(url, post_params) + update_mock.assert_called_once_with(mock.ANY, fip_id, + **expected_params) + + @test.create_mocks({api.neutron: ('tenant_floating_ip_get', + 'floating_ip_port_forwarding_list', + 'floating_ip_port_forwarding_delete')}) + def test_delete_floating_ip_portforwarding(self): + fip = self._get_fip_targets()[0] + self.mock_tenant_floating_ip_get.return_value = fip + fip_id = fip.id + self.mock_floating_ip_port_forwarding_list.return_value = ( + fip.port_forwardings) + deletion_mock = self.mock_floating_ip_port_forwarding_delete + pf_id = fip.port_forwardings[0].id + params = urlencode({'floating_ip_id': fip_id}) + url = '?'.join([reverse('%s:show' % NAMESPACE), params]) + self.client.post(url, { + 'action': 'floating_ip_portforwardings__delete__%s' % pf_id}) + deletion_mock.assert_called_once_with(mock.ANY, fip_id, pf_id) diff --git a/openstack_dashboard/dashboards/project/floating_ip_portforwardings/urls.py b/openstack_dashboard/dashboards/project/floating_ip_portforwardings/urls.py new file mode 100644 index 0000000000..6905e4a9a8 --- /dev/null +++ b/openstack_dashboard/dashboards/project/floating_ip_portforwardings/urls.py @@ -0,0 +1,24 @@ +# 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 django.urls import re_path + +from openstack_dashboard.dashboards.project.floating_ip_portforwardings import ( + views) + +urlpatterns = [ + re_path(r'^$', views.AllRulesView.as_view(), name='index'), + re_path(r'^show$', views.IndexView.as_view(), name='show'), + re_path(r'^create/$', views.CreateView.as_view(), name='create'), + re_path(r'^edit/$', views.EditView.as_view(), name='edit'), + re_path(r'^editToAll/$', views.EditToAllView.as_view(), name='editToAll') +] diff --git a/openstack_dashboard/dashboards/project/floating_ip_portforwardings/views.py b/openstack_dashboard/dashboards/project/floating_ip_portforwardings/views.py new file mode 100644 index 0000000000..dc4a64eb1a --- /dev/null +++ b/openstack_dashboard/dashboards/project/floating_ip_portforwardings/views.py @@ -0,0 +1,110 @@ +# 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. + +""" +Views for managing floating IPs port forwardings +""" +import logging + +from django.utils.translation import gettext_lazy as _ + +from neutronclient.common import exceptions as neutron_exc + +from horizon import exceptions +from horizon import tables +from horizon import workflows + +from openstack_dashboard import api +from openstack_dashboard.dashboards.project.floating_ip_portforwardings import ( + tables as project_tables) +from openstack_dashboard.dashboards.project.floating_ip_portforwardings import ( + workflows as project_workflows) + +LOG = logging.getLogger(__name__) + + +class CreateView(workflows.WorkflowView): + workflow_class = ( + project_workflows.FloatingIpPortForwardingRuleCreationWorkflow) + + +class EditView(workflows.WorkflowView): + workflow_class = project_workflows.FloatingIpPortForwardingRuleEditWorkflow + + +class EditToAllView(workflows.WorkflowView): + workflow_class = ( + project_workflows.FloatingIpPortForwardingRuleEditWorkflowToAll) + + +class IndexView(tables.DataTableView): + table_class = project_tables.FloatingIpPortForwardingRulesTable + page_title = _("Manage floating IP port forwarding rules") + + def get_data(self): + try: + floating_ip_id = self.request.GET.get('floating_ip_id') + floating_ip = api.neutron.tenant_floating_ip_get(self.request, + floating_ip_id) + self.page_title = _( + "Manage floating IP port forwarding rules : " + str( + floating_ip.ip)) + return self.get_floating_ip_rules(floating_ip) + except neutron_exc.ConnectionFailed: + exceptions.handle(self.request) + except Exception: + exceptions.handle( + self.request, + _('Unable to retrieve floating IP port forwarding rules.')) + return [] + + def get_floating_ip_rules(self, floating_ip): + if floating_ip.port_id: + return [] + + floating_ip_portforwarding_rules = [] + external_ip_address = floating_ip.ip + floating_ip_id = floating_ip.id + port_forwarding_rules = api.neutron.floating_ip_port_forwarding_list( + self.request, floating_ip_id) + + for port_forwarding_rule in port_forwarding_rules: + setattr(port_forwarding_rule, 'external_ip_address', + external_ip_address) + + floating_ip_portforwarding_rules.extend(port_forwarding_rules) + + return floating_ip_portforwarding_rules + + +class AllRulesView(IndexView): + table_class = project_tables.AllFloatingIpPortForwardingRulesTable + + def get_data(self): + try: + return self.get_all_floating_ip_rules() + except neutron_exc.ConnectionFailed: + exceptions.handle(self.request) + except Exception: + exceptions.handle( + self.request, + _('Unable to retrieve floating IP port forwarding rules.')) + return [] + + def get_all_floating_ip_rules(self): + floating_ip_portforwarding_rules = [] + floating_ips = api.neutron.tenant_floating_ip_list(self.request) + for floating_ip in floating_ips: + floating_ip_portforwarding_rules.extend( + self.get_floating_ip_rules(floating_ip)) + + return floating_ip_portforwarding_rules diff --git a/openstack_dashboard/dashboards/project/floating_ip_portforwardings/workflows.py b/openstack_dashboard/dashboards/project/floating_ip_portforwardings/workflows.py new file mode 100644 index 0000000000..11c7b1b659 --- /dev/null +++ b/openstack_dashboard/dashboards/project/floating_ip_portforwardings/workflows.py @@ -0,0 +1,270 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import logging + +from django.core.exceptions import ValidationError +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from neutronclient.common import exceptions as neutron_exc + +from horizon import exceptions +from horizon import forms +from horizon import workflows + +from openstack_dashboard import api +from openstack_dashboard.dashboards.project.floating_ip_portforwardings import ( + tables as project_tables) + +LOG = logging.getLogger(__name__) + + +class CommonMetaData(object): + name = _("Description") + help_text = _( + "Description:" + "" + "IP floating rules define external specific traffic that is bound " + "from a public IP to an internal address of a specific port.\n" + "Protocol: The protocol configured for the IP forwarding rule. " + "You can choose between TCP and UDP.\n" + "External port: The external port of the floating IP that" + " will be " + "bound to the internal port in the internal address. This field" + " allow values " + "between 1 and 65535 and also support ranges using the following" + " format:\n" + "InitialPort:FinalPort where InitialPort <= FinalPort.\n" + "Internal port: The internal port of the given internal IP " + "address that will be bound to the port that is exposed to the " + "internet via the public floating IP. This field allow values " + "between 1 and 65535 and also support ranges using the following" + " format:\n" + "InitialPort:FinalPort where InitialPort <= FinalPort.\n" + "Internal IP address: The internal IP address where the " + "internal ports will be running.\n" + "Description: Describes the reason why this rule is being " + "created.") + + +class CreateFloatingIpPortForwardingRuleAction(workflows.Action): + protocol = forms.ThemableChoiceField( + required=True, + choices=project_tables.PROTOCOL_CHOICES, + label=_("Protocol")) + external_port = forms.CharField(max_length=11, label=_("External port")) + internal_port = forms.CharField(max_length=11, label=_("Internal port")) + internal_ip_address = forms.ThemableChoiceField(required=True, label=_( + "Internal IP address")) + description = forms.CharField(required=False, widget=forms.Textarea, + max_length=255, label=_("Description")) + floating_ip_id = forms.CharField(max_length=255, + widget=forms.HiddenInput()) + + class Meta(CommonMetaData): + pass + + def ignore_validation(self, portforward=None): + return False + + def validate_input_selects(self): + err_msg = "You must select a%s" + internal_ip_address = self.cleaned_data.get('internal_ip_address') + protocol = self.cleaned_data.get('protocol') + + if protocol == "Select a protocol": + raise ValidationError(message=err_msg % " Protocol.") + + if internal_ip_address in ('Select an IP-Address', + 'No ports available'): + raise ValidationError(message=err_msg % "n Ip-Address.") + + def clean(self): + request = self.request + if request.method == "GET": + return self.cleaned_data + + self.validate_input_selects() + + return self.cleaned_data + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + floating_ip_id = self.request.GET.get('floating_ip_id') + self.initial['floating_ip_id'] = floating_ip_id + + def populate_internal_ip_address_choices(self, request, context): + targets = api.neutron.floating_ip_target_list(self.request) + instances = sorted([(target.id, target.name) for target in targets], + key=lambda x: x[1]) + if instances: + instances.insert(0, ("Select an IP-Address", _( + "Select an IP-Address"))) + else: + instances = (("No ports available", _( + "No ports available")),) + return instances + + +class EditFloatingIpPortForwardingRuleAction( + CreateFloatingIpPortForwardingRuleAction): + portforwading_id = forms.CharField(max_length=255, + widget=forms.HiddenInput()) + instance_id = None + + class Meta(CommonMetaData): + pass + + def ignore_validation(self, portforward=None): + return (super().ignore_validation(portforward) or + portforward.id == self.cleaned_data.get( + 'portforwading_id')) + + def __init__(self, *args, **kwargs): + request = args[0] + if request.method == 'POST': + super().__init__( + *args, **kwargs) + else: + floating_ip_id = request.GET.get('floating_ip_id') + port_forwarding_id = request.GET.get('pfwd_id') + port_forwarding = api.neutron.floating_ip_port_forwarding_get( + request, floating_ip_id, port_forwarding_id) + port_forwarding_rule = port_forwarding['port_forwarding'] + self.instance_id = "%s_%s" % ( + port_forwarding_rule['internal_port_id'], + port_forwarding_rule['internal_ip_address']) + super().__init__( + *args, **kwargs) + self.initial['portforwading_id'] = port_forwarding_id + self.initial['protocol'] = str( + port_forwarding_rule['protocol']).upper() + self.initial['internal_port'] = port_forwarding_rule[ + 'internal_port_range'] + self.initial['external_port'] = port_forwarding_rule[ + 'external_port_range'] + if 'description' in port_forwarding_rule.keys(): + self.initial['description'] = port_forwarding_rule[ + 'description'] + + def populate_internal_ip_address_choices(self, request, context): + targets = api.neutron.floating_ip_target_list(self.request) + instances = sorted([(target.id, target.name) for target in targets], + key=lambda x: '0' + if x[0] == self.instance_id else x[1]) + return instances + + +class CreateFloatingIpPortForwardingRule(workflows.Step): + action_class = CreateFloatingIpPortForwardingRuleAction + contributes = ("internal_port", "protocol", "external_port", + "internal_ip_address", "description", "floating_ip_id", + "portforwading_id") + + def contribute(self, data, context): + context = super().contribute(data, context) + return context + + +class EditFloatingIpPortForwardingRule( + CreateFloatingIpPortForwardingRule): + action_class = EditFloatingIpPortForwardingRuleAction + + def contribute(self, data, context): + context = super().contribute(data, context) + return context + + +class FloatingIpPortForwardingRuleCreationWorkflow(workflows.Workflow): + slug = "floating_ip_port_forwarding_rule_creation" + name = _("Add floating IP port forwarding rule") + finalize_button_name = _("Add") + success_message = _('Floating IP port forwarding rule %s created. ' + 'It might take a few minutes to apply all rules.') + failure_message = _('Unable to create floating IP port forwarding rule' + ' %s.') + success_url = "horizon:project:floating_ip_portforwardings:show" + default_steps = (CreateFloatingIpPortForwardingRule,) + + def format_status_message(self, message): + if "%s" in message: + return message % self.context.get('ip_address', + _('unknown IP address')) + return message + + def handle_using_api_method(self, request, data, api_method, + **api_params): + try: + floating_ip_id = data['floating_ip_id'] + self.success_url = reverse( + self.success_url) + "?floating_ip_id=" + str( + floating_ip_id) + port_id, internal_ip = data['internal_ip_address'].split('_') + self.context['ip_address'] = internal_ip + param = {} + if data['description']: + param['description'] = data['description'] + if data['internal_port']: + param['internal_port'] = data['internal_port'] + if data['external_port']: + param['external_port'] = data['external_port'] + if internal_ip: + param['internal_ip_address'] = internal_ip + if data['protocol']: + param['protocol'] = data['protocol'] + if port_id: + param['internal_port_id'] = port_id + + param.update(**api_params) + api_method(request, floating_ip_id, **param) + + except neutron_exc.Conflict as ex: + msg = _('The requested instance port is already' + ' associated with another floating IP.') + LOG.exception(msg, ex) + exceptions.handle(request, msg) + self.failure_message = msg + return False + + except Exception: + exceptions.handle(request) + return False + return True + + def handle(self, request, data): + return self.handle_using_api_method( + request, data, api.neutron.floating_ip_port_forwarding_create) + + +class FloatingIpPortForwardingRuleEditWorkflow( + FloatingIpPortForwardingRuleCreationWorkflow): + slug = "floating_ip_port_forwarding_rule_edit" + name = _("Edit floating IP port forwarding rule") + finalize_button_name = _("Update") + success_message = _('Floating IP port forwarding rule %s updated. ' + 'It might take a few minutes to apply all rules.') + failure_message = _('Unable to updated floating IP port forwarding' + ' rule %s.') + success_url = "horizon:project:floating_ip_portforwardings:show" + default_steps = (EditFloatingIpPortForwardingRule,) + + def handle(self, request, data): + return self.handle_using_api_method( + request, data, api.neutron.floating_ip_port_forwarding_update, + portforwarding_id=data['portforwading_id']) + + +class FloatingIpPortForwardingRuleEditWorkflowToAll( + FloatingIpPortForwardingRuleEditWorkflow): + slug = "floating_ip_port_forwarding_rule_edit_all" + success_url = "horizon:project:floating_ip_portforwardings:index" diff --git a/openstack_dashboard/dashboards/project/floating_ips/tables.py b/openstack_dashboard/dashboards/project/floating_ips/tables.py index 1fa3d579ef..dbaedc08c2 100644 --- a/openstack_dashboard/dashboards/project/floating_ips/tables.py +++ b/openstack_dashboard/dashboards/project/floating_ips/tables.py @@ -90,12 +90,60 @@ class ReleaseIPs(tables.BatchAction): def allowed(self, request, fip=None): policy_rules = (("network", "delete_floatingip"),) - return policy.check(policy_rules, request) + + port_forwarding_occurrence = 0 + + if fip: + pwds = fip.port_forwardings + port_forwarding_occurrence = len(pwds) + + return port_forwarding_occurrence == 0 and policy.check(policy_rules, + request) def action(self, request, obj_id): api.neutron.tenant_floating_ip_release(request, obj_id) +class ReleaseIPsPortForwarding(ReleaseIPs): + name = "release_floating_ip_portforwarding_rule" + help_text = _( + "This floating IP has port forwarding rules configured to it." + " Therefore," + " you will need to remove all of these rules before being able" + " to release it.") + + def __init__(self, **kwargs): + attributes = {"title": "Release Floating IP with port forwarding rules", + "confirm-button-text": "Edit floating IP port" + " forwarding rules"} + super().__init__(attrs=attributes, **kwargs) + + @staticmethod + def action_past(count): + return ngettext_lazy( + u"Successfully redirected", + u"Successfully redirected", + count + ) + + def allowed(self, request, fip=None): + + policy_rules = (("network", "delete_floatingip_port_forwarding"),) + pwds = fip.port_forwardings + return ( + len(pwds) > 0 and + policy.check(policy_rules, request) and + api.neutron.is_extension_floating_ip_port_forwarding_supported( + request) + ) + + def action(self, request, obj_id): + self.success_url = reverse( + 'horizon:project:floating_ip_portforwardings:show') \ + + '?floating_ip_id=' \ + + str(obj_id) + + class AssociateIP(tables.LinkAction): name = "associate" verbose_name = _("Associate") @@ -105,7 +153,9 @@ class AssociateIP(tables.LinkAction): def allowed(self, request, fip): policy_rules = (("network", "update_floatingip"),) - return not fip.port_id and policy.check(policy_rules, request) + pwds = fip.port_forwardings + return len(pwds) == 0 and not fip.port_id and policy.check(policy_rules, + request) def get_link_url(self, datum): base_url = reverse(self.url) @@ -113,6 +163,59 @@ class AssociateIP(tables.LinkAction): return "?".join([base_url, params]) +class ListAllFloatingIpPortForwardingRules(tables.LinkAction): + name = "List floating_ip_portforwardings_rules" + verbose_name = _("List all floating IP port forwarding rules") + url = "horizon:project:floating_ip_portforwardings:index" + classes = ("btn-edit",) + icon = "link" + + def exists_floating_ip_with_port_forwarding_rules_configurable(self, + request): + floating_ips = api.neutron.tenant_floating_ip_list(request) + for floating_ip in floating_ips: + if not floating_ip.port_id: + return True + + return False + + def allowed(self, request, fip): + policy_rules = (("network", "get_floatingip_port_forwarding"),) + return (self.exists_floating_ip_with_port_forwarding_rules_configurable( + request) and policy.check(policy_rules, request) and + api.neutron.is_extension_floating_ip_port_forwarding_supported( + request)) + + +class ConfigureFloatingIpPortForwarding(tables.Action): + name = "configure_floating_ip_portforwarding_rules" + verbose_name = _("Configure floating IP port forwarding rules") + classes = ("btn-edit",) + icon = "link" + + def allowed(self, request, fip): + policy_rules = (("network", "get_floatingip_port_forwarding"),) + return ( + not fip.port_id and + policy.check(policy_rules, request) and + api.neutron.is_extension_floating_ip_port_forwarding_supported( + request) + ) + + def single(self, table, request, obj_id): + fip = {} + try: + fip = table.get_object_by_id(filters.get_int_or_uuid(obj_id)) + except Exception as ex: + err_msg = 'Unable to find a floating IP.' + LOG.debug(err_msg, ex) + exceptions.handle(request, + _('Unable to find a floating IP.')) + return shortcuts.redirect( + reverse('horizon:project:floating_ip_portforwardings:show') + + '?floating_ip_id=' + str(fip.id)) + + class DisassociateIP(tables.Action): name = "disassociate" verbose_name = _("Disassociate") @@ -163,7 +266,6 @@ STATUS_DISPLAY_CHOICES = ( ("error", pgettext_lazy("Current status of a Floating IP", "Error")), ) - FLOATING_IPS_FILTER_CHOICES = ( ('floating_ip_address', _('Floating IP Address ='), True), ('network_id', _('Network ID ='), True), @@ -224,5 +326,9 @@ class FloatingIPsTable(tables.DataTable): class Meta(object): name = "floating_ips" verbose_name = _("Floating IPs") - table_actions = (AllocateIP, ReleaseIPs, FloatingIPsFilterAction) - row_actions = (AssociateIP, DisassociateIP, ReleaseIPs) + table_actions = ( + ListAllFloatingIpPortForwardingRules, AllocateIP, ReleaseIPs, + FloatingIPsFilterAction) + row_actions = (AssociateIP, DisassociateIP, ReleaseIPs, + ReleaseIPsPortForwarding, + ConfigureFloatingIpPortForwarding) diff --git a/openstack_dashboard/dashboards/project/floating_ips/tests.py b/openstack_dashboard/dashboards/project/floating_ips/tests.py index 8c94c1f1fc..ecaff90481 100644 --- a/openstack_dashboard/dashboards/project/floating_ips/tests.py +++ b/openstack_dashboard/dashboards/project/floating_ips/tests.py @@ -35,6 +35,13 @@ NAMESPACE = "horizon:project:floating_ips" class FloatingIpViewTests(test.TestCase): + def setUp(self): + super().setUp() + api_mock = mock.patch.object( + api.neutron, + 'is_extension_floating_ip_port_forwarding_supported').start() + api_mock.return_value = True + @test.create_mocks({api.neutron: ('floating_ip_target_list', 'tenant_floating_ip_list')}) def test_associate(self): @@ -42,7 +49,6 @@ class FloatingIpViewTests(test.TestCase): self._get_fip_targets() self.mock_tenant_floating_ip_list.return_value = \ self.floating_ips.list() - url = reverse('%s:associate' % NAMESPACE) res = self.client.get(url) self.assertTemplateUsed(res, views.WorkflowView.template_name) @@ -91,6 +97,7 @@ class FloatingIpViewTests(test.TestCase): for ip in p.fixed_ips: targets.append(api.neutron.FloatingIpTarget( p, ip['ip_address'], server_dict.get(p.device_id))) + targets[-1].port_forwardings = [] return targets @staticmethod @@ -213,12 +220,14 @@ class FloatingIpViewTests(test.TestCase): @test.create_mocks({api.nova: ('server_list',), api.neutron: ('floating_ip_disassociate', 'floating_ip_pools_list', + 'floating_ip_port_forwarding_list', 'is_extension_supported', 'tenant_floating_ip_list')}) def test_disassociate_post(self): floating_ip = self.floating_ips.first() self.mock_is_extension_supported.return_value = False + self.mock_floating_ip_port_forwarding_list.return_value = [] self.mock_server_list.return_value = [self.servers.list(), False] self.mock_tenant_floating_ip_list.return_value = \ self.floating_ips.list() @@ -243,6 +252,7 @@ class FloatingIpViewTests(test.TestCase): @test.create_mocks({api.nova: ('server_list',), api.neutron: ('floating_ip_disassociate', + 'floating_ip_port_forwarding_list', 'floating_ip_pools_list', 'is_extension_supported', 'tenant_floating_ip_list')}) @@ -250,6 +260,7 @@ class FloatingIpViewTests(test.TestCase): floating_ip = self.floating_ips.first() self.mock_is_extension_supported.return_value = False + self.mock_floating_ip_port_forwarding_list.return_value = [] self.mock_server_list.return_value = [self.servers.list(), False] self.mock_tenant_floating_ip_list.return_value = \ self.floating_ips.list() @@ -273,6 +284,7 @@ class FloatingIpViewTests(test.TestCase): @test.create_mocks({api.neutron: ('tenant_floating_ip_list', 'is_extension_supported', + 'floating_ip_port_forwarding_list', 'floating_ip_pools_list'), api.nova: ('server_list',), quotas: ('tenant_quota_usages',)}) @@ -283,6 +295,7 @@ class FloatingIpViewTests(test.TestCase): self.mock_is_extension_supported.return_value = False self.mock_tenant_floating_ip_list.return_value = floating_ips + self.mock_floating_ip_port_forwarding_list.return_value = [] self.mock_floating_ip_pools_list.return_value = floating_pools self.mock_server_list.return_value = [self.servers.list(), False] self.mock_tenant_quota_usages.return_value = quota_data @@ -298,9 +311,9 @@ class FloatingIpViewTests(test.TestCase): url = 'horizon:project:floating_ips:allocate' self.assertEqual(url, allocate_action.url) - self.mock_tenant_floating_ip_list.assert_called_once_with( + self.mock_tenant_floating_ip_list.assert_called_with( test.IsHttpRequest()) - self.mock_floating_ip_pools_list.assert_called_once_with( + self.mock_floating_ip_pools_list.assert_called_with( test.IsHttpRequest()) self.mock_server_list.assert_called_once_with(test.IsHttpRequest(), detailed=False) @@ -313,6 +326,7 @@ class FloatingIpViewTests(test.TestCase): @test.create_mocks({api.neutron: ('tenant_floating_ip_list', 'is_extension_supported', + 'floating_ip_port_forwarding_list', 'floating_ip_pools_list'), api.nova: ('server_list',), quotas: ('tenant_quota_usages',)}) @@ -324,6 +338,7 @@ class FloatingIpViewTests(test.TestCase): self.mock_is_extension_supported.return_value = False self.mock_tenant_floating_ip_list.return_value = floating_ips + self.mock_floating_ip_port_forwarding_list.return_value = [] self.mock_floating_ip_pools_list.return_value = floating_pools self.mock_server_list.return_value = [self.servers.list(), False] self.mock_tenant_quota_usages.return_value = quota_data @@ -337,9 +352,9 @@ class FloatingIpViewTests(test.TestCase): self.assertEqual('Allocate IP To Project (Quota exceeded)', allocate_action.verbose_name) - self.mock_tenant_floating_ip_list.assert_called_once_with( + self.mock_tenant_floating_ip_list.assert_called_with( test.IsHttpRequest()) - self.mock_floating_ip_pools_list.assert_called_once_with( + self.mock_floating_ip_pools_list.assert_called_with( test.IsHttpRequest()) self.mock_server_list.assert_called_once_with(test.IsHttpRequest(), detailed=False) diff --git a/openstack_dashboard/dashboards/project/floating_ips/views.py b/openstack_dashboard/dashboards/project/floating_ips/views.py index 879d4c5b54..e211181cd4 100644 --- a/openstack_dashboard/dashboards/project/floating_ips/views.py +++ b/openstack_dashboard/dashboards/project/floating_ips/views.py @@ -20,6 +20,7 @@ """ Views for managing floating IPs. """ +import logging from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ @@ -41,6 +42,8 @@ from openstack_dashboard.dashboards.project.floating_ips \ from openstack_dashboard.dashboards.project.floating_ips \ import workflows as project_workflows +LOG = logging.getLogger(__name__) + class AssociateView(workflows.WorkflowView): workflow_class = project_workflows.IPAssociationWorkflow @@ -129,8 +132,20 @@ class IndexView(tables.DataTableView): instances_dict = dict((obj.id, obj.name) for obj in instances) + fip_pfw_enabled = ( + api.neutron.is_extension_floating_ip_port_forwarding_supported( + self.request)) + for ip in floating_ips: ip.instance_name = instances_dict.get(ip.instance_id) ip.pool_name = pool_dict.get(ip.pool, ip.pool) + if fip_pfw_enabled: + try: + pfws = api.neutron.floating_ip_port_forwarding_list( + self.request, ip.id) + ip.port_forwardings = pfws + except Exception as e: + LOG.info("Error fetching port forwardings for floating IP" + " %s: %s", ip.id, e) return floating_ips diff --git a/openstack_dashboard/dashboards/project/floating_ips/workflows.py b/openstack_dashboard/dashboards/project/floating_ips/workflows.py index 5defa54d16..dcc13fcdbe 100644 --- a/openstack_dashboard/dashboards/project/floating_ips/workflows.py +++ b/openstack_dashboard/dashboards/project/floating_ips/workflows.py @@ -94,7 +94,8 @@ class AssociateIPAction(workflows.Action): exceptions.handle(self.request, _('Unable to retrieve floating IP addresses.'), redirect=redirect) - options = sorted([(ip.id, ip.ip) for ip in ips if not ip.port_id]) + options = sorted([(ip.id, ip.ip) for ip in ips if + not ip.port_id and len(ip.port_forwardings) == 0]) if options: options.insert(0, ("", _("Select an IP address"))) else: @@ -124,7 +125,7 @@ class AssociateIPAction(workflows.Action): # The reason of specifying an empty tuple when q_instance_id is None # is to make memoized_method _get_target_list work. Two calls of # _get_target_list from here and __init__ must have a same arguments. - params = (q_instance_id, ) if q_instance_id else () + params = (q_instance_id,) if q_instance_id else () targets = self._get_target_list(*params) instances = sorted([(target.id, target.name) for target in targets], # Sort FIP targets by server name for easy browsing diff --git a/openstack_dashboard/enabled/_1520_project_floating_ip_portforwardings_panel.py b/openstack_dashboard/enabled/_1520_project_floating_ip_portforwardings_panel.py new file mode 100644 index 0000000000..fc3c089165 --- /dev/null +++ b/openstack_dashboard/enabled/_1520_project_floating_ip_portforwardings_panel.py @@ -0,0 +1,7 @@ +PANEL_DASHBOARD = 'project' +PANEL_GROUP = 'network' +PANEL = 'floating_ip_portforwardings' + +ADD_PANEL = \ + 'openstack_dashboard.dashboards.project.floating_ip_portforwardings.panel' \ + '.FloatingIpPortforwardingRules' diff --git a/openstack_dashboard/test/test_data/neutron_data.py b/openstack_dashboard/test/test_data/neutron_data.py index 78bd93df31..024ec79bcf 100644 --- a/openstack_dashboard/test/test_data/neutron_data.py +++ b/openstack_dashboard/test/test_data/neutron_data.py @@ -34,6 +34,7 @@ def data(TEST): TEST.routers_with_rules = utils.TestDataContainer() TEST.routers_with_routes = utils.TestDataContainer() TEST.floating_ips = utils.TestDataContainer() + TEST.port_forwardings = utils.TestDataContainer() TEST.security_groups = utils.TestDataContainer() TEST.security_group_rules = utils.TestDataContainer() TEST.providers = utils.TestDataContainer() @@ -63,6 +64,7 @@ def data(TEST): TEST.api_routers = utils.TestDataContainer() TEST.api_routers_with_routes = utils.TestDataContainer() TEST.api_floating_ips = utils.TestDataContainer() + TEST.api_port_forwardings = utils.TestDataContainer() TEST.api_security_groups = utils.TestDataContainer() TEST.api_security_group_rules = utils.TestDataContainer() TEST.api_pools = utils.TestDataContainer() @@ -647,6 +649,7 @@ def data(TEST): 'id': '9012cd70-cfae-4e46-b71e-6a409e9e0063', 'fixed_ip_address': None, 'port_id': None, + 'port_forwardings': [], 'router_id': None} TEST.api_floating_ips.add(fip_dict) fip_with_instance = copy.deepcopy(fip_dict) @@ -659,6 +662,7 @@ def data(TEST): 'floating_ip_address': '172.16.88.228', 'floating_network_id': ext_net['id'], 'id': 'a97af8f2-3149-4b97-abbd-e49ad19510f7', + 'port_forwardings': [], 'fixed_ip_address': assoc_port['fixed_ips'][0]['ip_address'], 'port_id': assoc_port['id'], 'router_id': router_dict['id']} @@ -668,6 +672,46 @@ def data(TEST): 'instance_type': 'compute'}) TEST.floating_ips.add(neutron.FloatingIp(fip_with_instance)) + # port forwardings + + TEST.api_port_forwardings.add({ + "protocol": "tcp", + "internal_ip_address": "10.0.0.11", + "internal_port": 25, + "internal_port_id": "1238be08-a2a8-4b8d-addf-fb5e2250e480", + "external_port": 2230, + "internal_port_range": "25:25", + "external_port_range": "2230:2230", + "description": "", + "id": "e0a0274e-4d19-4eab-9e12-9e77a8caf3ea" + }) + TEST.api_port_forwardings.add({ + "protocol": "tcp", + "internal_port": 80, + "external_port": 8080, + "internal_ip_address": "10.0.0.12", + "internal_port_range": "80:90", + "internal_port_id": "2057ec54-8be2-11eb-8dcd-0242ac130003", + "external_port_range": "8080:8090", + "description": "using port ranges", + "id": "0f23a90a-8be2-11eb-8dcd-0242ac130003" + }) + TEST.api_port_forwardings.add({ + "protocol": "tcp", + "internal_ip_address": "10.0.0.24", + "internal_port": 25, + "internal_port_id": "070ef0b2-0175-4299-be5c-01fea8cca522", + "external_port": 2229, + "internal_port_range": "25:25", + "external_port_range": "2229:2229", + "description": "Some description", + "id": "1798dc82-c0ed-4b79-b12d-4c3c18f90eb2" + }) + + TEST.port_forwardings.add(neutron.PortForwarding( + TEST.api_port_forwardings.first(), fip_dict['id'] + )) + # Security group. sec_group_1 = {'tenant_id': '1', diff --git a/openstack_dashboard/test/unit/api/test_neutron.py b/openstack_dashboard/test/unit/api/test_neutron.py index 19bde8df1f..4d9d1e8d03 100644 --- a/openstack_dashboard/test/unit/api/test_neutron.py +++ b/openstack_dashboard/test/unit/api/test_neutron.py @@ -2321,6 +2321,81 @@ class NeutronApiSecurityGroupTests(test.APIMockTestCase): self.qclient.update_port.assert_has_calls(expected_calls) +class NeutronApiFloatingIpPortForwardingTest(test.APIMockTestCase): + def setUp(self): + super().setUp() + neutronclient = mock.patch.object(api.neutron, 'neutronclient').start() + self.client_mock = neutronclient.return_value + + def test_port_forwarding_list(self): + pfws = {'port_forwardings': self.api_port_forwardings.list()} + self.client_mock.list_port_forwardings.return_value = pfws + response = api.neutron.floating_ip_port_forwarding_list( + self.request, 'fip') + for i in range(len(response)): + resp_val = response[i] + expected_val = pfws['port_forwardings'][i] + for attr in resp_val.to_dict(): + self.assertEqual(getattr(resp_val, attr), expected_val[attr]) + + self.client_mock.list_port_forwardings.assert_called_once_with('fip') + + def test_port_forwarding_get(self): + pfw = self.api_port_forwardings.first() + pfw_id = pfw['id'] + self.client_mock.show_port_forwarding.return_value = pfw + response = api.neutron.floating_ip_port_forwarding_get( + self.request, 'fip', pfw_id) + for attr in response.to_dict(): + self.assertEqual(getattr(response, attr), pfw[attr]) + self.client_mock.show_port_forwarding.assert_called_once_with( + 'fip', pfw_id) + + def test_port_forwarding_create(self): + pfw_resp_mock = {'port_forwarding': self.api_port_forwardings.first()} + pfw_expected = self.port_forwardings.get().to_dict() + pfw = { + "protocol": "tcp", + "internal_ip_address": "10.0.0.24", + "internal_port": 25, + "internal_port_id": "070ef0b2-0175-4299-be5c-01fea8cca522", + "external_port": 2229, + "description": "Some description", + } + self.client_mock.create_port_forwarding.return_value = pfw_resp_mock + response = api.neutron.floating_ip_port_forwarding_create( + self.request, 'fip', **pfw) + for attr in response.to_dict(): + self.assertEqual(getattr(response, attr), pfw_expected[attr]) + self.client_mock.create_port_forwarding.assert_called_once_with( + 'fip', {'port_forwarding': pfw}) + + def test_port_forwarding_update(self): + pfw_resp_mock = {'port_forwarding': self.api_port_forwardings.first()} + pfw_expected = self.port_forwardings.get().to_dict() + pfw_id = pfw_resp_mock['port_forwarding']['id'] + pfw = { + "protocol": "tcp", + "internal_port": 25, + "description": "Some description", + } + self.client_mock.update_port_forwarding.return_value = pfw_resp_mock + response = api.neutron.floating_ip_port_forwarding_update( + self.request, 'fip', portforwarding_id=pfw_id, **pfw) + for attr in response.to_dict(): + self.assertEqual(getattr(response, attr), pfw_expected[attr]) + self.client_mock.update_port_forwarding.assert_called_once_with( + 'fip', pfw_id, {'port_forwarding': pfw}) + + def test_port_forwarding_delete(self): + pfw_id = self.api_port_forwardings.first()['id'] + self.client_mock.delete_port_forwarding.return_value = None + api.neutron.floating_ip_port_forwarding_delete( + self.request, 'fip', pfw_id) + self.client_mock.delete_port_forwarding.assert_called_once_with( + 'fip', pfw_id) + + class NeutronApiFloatingIpTests(test.APIMockTestCase): def setUp(self): diff --git a/releasenotes/notes/add-support-to-floating-ip-port-forwardings-3d0d43a2d997ce79.yaml b/releasenotes/notes/add-support-to-floating-ip-port-forwardings-3d0d43a2d997ce79.yaml new file mode 100644 index 0000000000..5b9b97a5cb --- /dev/null +++ b/releasenotes/notes/add-support-to-floating-ip-port-forwardings-3d0d43a2d997ce79.yaml @@ -0,0 +1,8 @@ +--- +features: + - | + Add support to portforwardings in the Network Floating IPs dashboard. + + Requires python-neutronclient >= 8.1.0 + + This feature is disabled by default. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index b252214c95..758643d201 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,7 +37,7 @@ pyScss>=1.4.0 # MIT License python-cinderclient>=8.0.0 # Apache-2.0 python-glanceclient>=2.8.0 # Apache-2.0 python-keystoneclient>=3.22.0 # Apache-2.0 -python-neutronclient>=6.7.0 # Apache-2.0 +python-neutronclient>=8.1.0 # Apache-2.0 python-novaclient>=9.1.0 # Apache-2.0 python-swiftclient>=3.2.0 # Apache-2.0 pytz>=2013.6 # MIT