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.