diff --git a/openstack_dashboard/dashboards/project/instances/interfaces_tables.py b/openstack_dashboard/dashboards/project/instances/interfaces_tables.py new file mode 100644 index 0000000000..902a2c747f --- /dev/null +++ b/openstack_dashboard/dashboards/project/instances/interfaces_tables.py @@ -0,0 +1,62 @@ +# 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 reverse +from django.utils.http import urlencode +from django.utils.translation import ugettext_lazy as _ + +from horizon import tables + +from openstack_dashboard.dashboards.project.networks.ports \ + import tables as port_tables + + +class UpdatePort(port_tables.UpdatePort): + url = "horizon:project:instances:update_port" + + def get_link_url(self, port): + instance_id = self.table.kwargs['instance_id'] + base_url = reverse(self.url, args=(instance_id, port.id)) + params = {'step': 'update_info'} + param = urlencode(params) + return '?'.join([base_url, param]) + + +class UpdateSecurityGroups(port_tables.UpdatePort): + name = 'update_security_groups' + verbose_name = _('Edit Security Groups') + url = "horizon:project:instances:update_port" + classes = ("ajax-modal",) + icon = "pencil" + policy_rules = (("network", "update_port"),) + + def get_link_url(self, port): + instance_id = self.table.kwargs['instance_id'] + base_url = reverse(self.url, args=(instance_id, port.id)) + params = {'step': 'update_security_groups'} + param = urlencode(params) + return '?'.join([base_url, param]) + + def allowed(self, request, datum=None): + return datum and datum.port_security_enabled + + +class InterfacesTable(port_tables.PortsTable): + network = tables.Column('network', verbose_name=_('Network')) + + class Meta(object): + name = "interfaces" + verbose_name = _("Interfaces") + table_actions = [] + row_actions = [UpdateSecurityGroups, UpdatePort] + columns = ('name', 'network', 'fixed_ips', 'mac_address', + 'status', 'admin_state', 'mac_state') diff --git a/openstack_dashboard/dashboards/project/instances/tabs.py b/openstack_dashboard/dashboards/project/instances/tabs.py index 3aac4600b8..a8dc2793b3 100644 --- a/openstack_dashboard/dashboards/project/instances/tabs.py +++ b/openstack_dashboard/dashboards/project/instances/tabs.py @@ -25,6 +25,7 @@ from openstack_dashboard.dashboards.project.instances \ from openstack_dashboard import api from openstack_dashboard.dashboards.project.instances import console +from openstack_dashboard.dashboards.project.instances import interfaces_tables class OverviewTab(tabs.Tab): @@ -38,6 +39,32 @@ class OverviewTab(tabs.Tab): "is_superuser": request.user.is_superuser} +class InterfacesTab(tabs.TableTab): + name = _("Interfaces") + slug = "interfaces" + table_classes = (interfaces_tables.InterfacesTable, ) + template_name = "horizon/common/_detail_table.html" + preload = False + + def get_interfaces_data(self): + instance = self.tab_group.kwargs['instance'] + try: + ports = api.neutron.port_list(self.request, device_id=instance.id) + if ports: + net_ids = [p.network_id for p in ports] + networks = api.neutron.network_list(self.request, id=net_ids) + net_dict = dict((n.id, n.name_or_id) for n in networks) + else: + net_dict = {} + for p in ports: + p.network = net_dict.get(p.network_id) + except Exception: + exceptions.handle(self.request, + _('Failed to get instance interfaces.')) + ports = [] + return ports + + class LogTab(tabs.Tab): name = _("Log") slug = "log" @@ -110,5 +137,5 @@ class AuditTab(tabs.TableTab): class InstanceDetailTabs(tabs.DetailTabsGroup): slug = "instance_details" - tabs = (OverviewTab, LogTab, ConsoleTab, AuditTab) + tabs = (OverviewTab, InterfacesTab, LogTab, ConsoleTab, AuditTab) sticky = True diff --git a/openstack_dashboard/dashboards/project/instances/tests.py b/openstack_dashboard/dashboards/project/instances/tests.py index 1e48b3f8ac..e971913c48 100644 --- a/openstack_dashboard/dashboards/project/instances/tests.py +++ b/openstack_dashboard/dashboards/project/instances/tests.py @@ -1350,22 +1350,30 @@ class InstanceDetailTests(InstanceTestBase): return res + @helpers.create_mocks({api.neutron: ['is_extension_supported']}) def test_instance_details_volumes(self): server = self.servers.first() volumes = [self.volumes.list()[1]] security_groups = self.security_groups.list() + self.mock_is_extension_supported.return_value = False + res = self._get_instance_details( server, volumes_return=volumes, security_groups_return=security_groups) self.assertItemsEqual(res.context['instance'].volumes, volumes) + self.mock_is_extension_supported.assert_called_once_with( + helpers.IsHttpRequest(), 'mac-learning') + @helpers.create_mocks({api.neutron: ['is_extension_supported']}) def test_instance_details_volume_sorting(self): server = self.servers.first() volumes = self.volumes.list()[1:3] security_groups = self.security_groups.list() + self.mock_is_extension_supported.return_value = False + res = self._get_instance_details( server, volumes_return=volumes, security_groups_return=security_groups) @@ -1375,10 +1383,15 @@ class InstanceDetailTests(InstanceTestBase): "/dev/hda") self.assertEqual(res.context['instance'].volumes[1].device, "/dev/hdk") + self.mock_is_extension_supported.assert_called_once_with( + helpers.IsHttpRequest(), 'mac-learning') + @helpers.create_mocks({api.neutron: ['is_extension_supported']}) def test_instance_details_metadata(self): server = self.servers.first() + self.mock_is_extension_supported.return_value = False + tg = tabs.InstanceDetailTabs(self.request, instance=server) qs = "?%s=%s" % (tg.param_name, tg.get_tab("overview").get_id()) res = self._get_instance_details(server, qs) @@ -1392,9 +1405,16 @@ class InstanceDetailTests(InstanceTestBase): self.assertContains(res, "
empty
", 1) self.assertContains(res, "
N/A
", 1) + self.assert_mock_multiple_calls_with_same_arguments( + self.mock_is_extension_supported, 2, + mock.call(helpers.IsHttpRequest(), 'mac-learning')) + + @helpers.create_mocks({api.neutron: ['is_extension_supported']}) def test_instance_details_fault(self): server = self.servers.first() + self.mock_is_extension_supported.return_value = False + server.status = 'ERROR' server.fault = {"message": "NoValidHost", "code": 500, @@ -1408,8 +1428,11 @@ class InstanceDetailTests(InstanceTestBase): res = self._get_instance_details(server) self.assertItemsEqual(res.context['instance'].fault, server.fault) + self.mock_is_extension_supported.assert_called_once_with( + helpers.IsHttpRequest(), 'mac-learning') - @helpers.create_mocks({console: ('get_console',)}) + @helpers.create_mocks({console: ['get_console'], + api.neutron: ['is_extension_supported']}) def test_instance_details_console_tab(self): server = self.servers.first() CONSOLE_OUTPUT = '/vncserver' @@ -1417,6 +1440,7 @@ class InstanceDetailTests(InstanceTestBase): CONSOLE_URL = CONSOLE_OUTPUT + CONSOLE_TITLE self.mock_get_console.return_value = ('VNC', CONSOLE_URL) + self.mock_is_extension_supported.return_value = False tg = tabs.InstanceDetailTabs(self.request, instance=server) qs = "?%s=%s" % (tg.param_name, tg.get_tab("console").get_id()) @@ -1432,11 +1456,17 @@ class InstanceDetailTests(InstanceTestBase): self.assertTrue(console_tab_rendered) self.mock_get_console.assert_called_once_with( mock.ANY, 'AUTO', server) + self.assert_mock_multiple_calls_with_same_arguments( + self.mock_is_extension_supported, 2, + mock.call(helpers.IsHttpRequest(), 'mac-learning')) @django.test.utils.override_settings(CONSOLE_TYPE=None) + @helpers.create_mocks({api.neutron: ['is_extension_supported']}) def test_instance_details_console_tab_deactivated(self): server = self.servers.first() + self.mock_is_extension_supported.return_value = False + tg = tabs.InstanceDetailTabs(self.request, instance=server) self.assertIsNone(tg.get_tab("console")) res = self._get_instance_details(server) @@ -1445,6 +1475,10 @@ class InstanceDetailTests(InstanceTestBase): for tab in res.context_data['tab_group'].get_loaded_tabs(): self.assertNotIsInstance(tab, tabs.ConsoleTab) + self.assert_mock_multiple_calls_with_same_arguments( + self.mock_is_extension_supported, 2, + mock.call(helpers.IsHttpRequest(), 'mac-learning')) + @helpers.create_mocks({api.nova: ('server_get',)}) def test_instance_details_exception(self): server = self.servers.first() @@ -1475,19 +1509,25 @@ class InstanceDetailTests(InstanceTestBase): self.assertEqual(403, res.status_code) self.assertEqual(0, self.mock_server_get.call_count) + @helpers.create_mocks({api.neutron: ['is_extension_supported']}) def test_instance_details_flavor_not_found(self): server = self.servers.first() + self.mock_is_extension_supported.return_value = False res = self._get_instance_details(server, flavor_exception=True) self.assertTemplateUsed(res, 'project/instances/_detail_overview.html') self.assertContains(res, "Not available") + self.mock_is_extension_supported.assert_called_once_with( + helpers.IsHttpRequest(), 'mac-learning') - @helpers.create_mocks({api.nova: ('server_console_output',)}) + @helpers.create_mocks({api.nova: ['server_console_output'], + api.neutron: ['is_extension_supported']}) def test_instance_log(self): server = self.servers.first() CONSOLE_OUTPUT = 'output' self.mock_server_console_output.return_value = CONSOLE_OUTPUT + self.mock_is_extension_supported.return_value = False url = reverse('horizon:project:instances:console', args=[server.id]) @@ -1500,12 +1540,16 @@ class InstanceDetailTests(InstanceTestBase): self.assertContains(res, CONSOLE_OUTPUT) self.mock_server_console_output.assert_called_once_with( helpers.IsHttpRequest(), server.id, tail_length=None) + self.mock_is_extension_supported.assert_called_once_with( + helpers.IsHttpRequest(), 'mac-learning') - @helpers.create_mocks({api.nova: ('server_console_output',)}) + @helpers.create_mocks({api.nova: ['server_console_output'], + api.neutron: ['is_extension_supported']}) def test_instance_log_exception(self): server = self.servers.first() self.mock_server_console_output.side_effect = self.exceptions.nova + self.mock_is_extension_supported.return_value = False url = reverse('horizon:project:instances:console', args=[server.id]) @@ -1516,9 +1560,13 @@ class InstanceDetailTests(InstanceTestBase): self.assertContains(res, "Unable to get log for") self.mock_server_console_output.assert_called_once_with( helpers.IsHttpRequest(), server.id, tail_length=None) + self.mock_is_extension_supported.assert_called_once_with( + helpers.IsHttpRequest(), 'mac-learning') + @helpers.create_mocks({api.neutron: ['is_extension_supported']}) def test_instance_log_invalid_input(self): server = self.servers.first() + self.mock_is_extension_supported.return_value = False url = reverse('horizon:project:instances:console', args=[server.id]) @@ -1531,6 +1579,9 @@ class InstanceDetailTests(InstanceTestBase): self.assertContains(res, "Unable to get log for") + self.mock_is_extension_supported.assert_called_once_with( + helpers.IsHttpRequest(), 'mac-learning') + @helpers.create_mocks({api.nova: ['server_get'], console: ['get_console']}) def test_instance_auto_console(self): diff --git a/openstack_dashboard/dashboards/project/instances/urls.py b/openstack_dashboard/dashboards/project/instances/urls.py index 4e5a594839..4deb8055d7 100644 --- a/openstack_dashboard/dashboards/project/instances/urls.py +++ b/openstack_dashboard/dashboards/project/instances/urls.py @@ -54,5 +54,7 @@ urlpatterns = [ url(r'^(?P[^/]+)/detach_volume/$', views.DetachVolumeView.as_view(), name='detach_volume' - ) + ), + url(r'^(?P[^/]+)/ports/(?P[^/]+)/update$', + views.UpdatePortView.as_view(), name='update_port'), ] diff --git a/openstack_dashboard/dashboards/project/instances/views.py b/openstack_dashboard/dashboards/project/instances/views.py index 548c83d55a..9d39f43c62 100644 --- a/openstack_dashboard/dashboards/project/instances/views.py +++ b/openstack_dashboard/dashboards/project/instances/views.py @@ -51,6 +51,8 @@ from openstack_dashboard.dashboards.project.instances \ import tabs as project_tabs from openstack_dashboard.dashboards.project.instances \ import workflows as project_workflows +from openstack_dashboard.dashboards.project.networks.ports \ + import views as port_views from openstack_dashboard.utils import futurist_utils from openstack_dashboard.views import get_url_with_pagination @@ -655,3 +657,24 @@ class DetachInterfaceView(forms.ModalFormView): submit_url = "horizon:project:instances:detach_interface" self.submit_url = reverse(submit_url, kwargs=args) return args + + +class UpdatePortView(port_views.UpdateView): + workflow_class = project_workflows.UpdatePort + failure_url = 'horizon:project:instances:detail' + + @memoized.memoized_method + def _get_object(self, *args, **kwargs): + port_id = self.kwargs['port_id'] + try: + return api.neutron.port_get(self.request, port_id) + except Exception: + redirect = reverse(self.failure_url, + args=(self.kwargs['instance_id'],)) + msg = _('Unable to retrieve port details') + exceptions.handle(self.request, msg, redirect=redirect) + + def get_initial(self): + initial = super(UpdatePortView, self).get_initial() + initial['instance_id'] = self.kwargs['instance_id'] + return initial diff --git a/openstack_dashboard/dashboards/project/instances/workflows/__init__.py b/openstack_dashboard/dashboards/project/instances/workflows/__init__.py index 5cc19ca532..54ac87e84f 100644 --- a/openstack_dashboard/dashboards/project/instances/workflows/__init__.py +++ b/openstack_dashboard/dashboards/project/instances/workflows/__init__.py @@ -16,9 +16,12 @@ from openstack_dashboard.dashboards.project.instances.workflows.\ resize_instance import ResizeInstance from openstack_dashboard.dashboards.project.instances.workflows.\ update_instance import UpdateInstance +from openstack_dashboard.dashboards.project.instances.workflows.\ + update_port import UpdatePort __all__ = [ 'LaunchInstance', 'ResizeInstance', 'UpdateInstance', + 'UpdatePort', ] diff --git a/openstack_dashboard/dashboards/project/instances/workflows/update_port.py b/openstack_dashboard/dashboards/project/instances/workflows/update_port.py new file mode 100644 index 0000000000..7ad6991ad3 --- /dev/null +++ b/openstack_dashboard/dashboards/project/instances/workflows/update_port.py @@ -0,0 +1,31 @@ +# 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 reverse + +from openstack_dashboard.dashboards.project.networks.ports import workflows + + +class UpdatePortInfo(workflows.UpdatePortInfo): + depends_on = ('network_id', 'port_id', 'instance_id') + + +class UpdatePortSecurityGroup(workflows.UpdatePortSecurityGroup): + depends_on = ("port_id", 'target_tenant_id', 'instance_id') + + +class UpdatePort(workflows.UpdatePort): + default_steps = (UpdatePortInfo, UpdatePortSecurityGroup) + + def get_success_url(self): + return reverse("horizon:project:instances:detail", + args=(self.context['instance_id'],)) diff --git a/releasenotes/notes/edit-port-security-groups-f650fc98f5e10eb8.yaml b/releasenotes/notes/edit-port-security-groups-f650fc98f5e10eb8.yaml new file mode 100644 index 0000000000..4d3ce01824 --- /dev/null +++ b/releasenotes/notes/edit-port-security-groups-f650fc98f5e10eb8.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + "Interfaces" tab is added to the instance detail page. The new tab shows + a list of ports attached to an instance. Users now have an easy way to + access the list of ports of the instance and edit security groups per port.