Add instance interfaces tab for easy security group edit

There is no easy way to edit security groups of ports attached
to an instance. This commit adds a table of ports attached to
an insntace to the instance detail. Users now can access attached
ports easily and edit their security groups.

Partial-Bug: #1750147
Change-Id: Ia2bd19f92251702878be3b12d0ea2a5c6618c65e
This commit is contained in:
Akihiro Motoki 2018-02-17 22:03:50 +09:00
parent eea762663b
commit ff5b622da5
8 changed files with 210 additions and 5 deletions

View File

@ -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')

View File

@ -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

View File

@ -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, "<dt>empty</dt>", 1)
self.assertContains(res, "<dd><em>N/A</em></dd>", 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):

View File

@ -54,5 +54,7 @@ urlpatterns = [
url(r'^(?P<instance_id>[^/]+)/detach_volume/$',
views.DetachVolumeView.as_view(),
name='detach_volume'
)
),
url(r'^(?P<instance_id>[^/]+)/ports/(?P<port_id>[^/]+)/update$',
views.UpdatePortView.as_view(), name='update_port'),
]

View File

@ -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

View File

@ -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',
]

View File

@ -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'],))

View File

@ -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.