Add allowed address pair extension UI for neutron ports.

Changed the port detail view to a TabbedTableView where the extra tab is
enabled/disabled when the extension is active or not in neutron. Similar to
how extensions are handled for routers. If the extension is not active the
port detail screen should look the same as it does now.

The extra tab has a table of the allowed address pairs (columns: IP address,
MAC) with create and delete actions.

Change-Id: I07edb1afae5c2004761d1c118a724fb94aaebe3e
implements: blueprint port-allowed-address-pairs-extension
This commit is contained in:
Wim De Clercq 2016-02-08 09:06:34 +01:00 committed by Akihiro Motoki
parent dda1eb2cb6
commit c140149308
22 changed files with 508 additions and 8 deletions

View File

@ -20,6 +20,7 @@
from __future__ import absolute_import
import collections
import copy
import logging
import netaddr
@ -120,9 +121,23 @@ class Port(NeutronAPIDictWrapper):
if 'mac_learning_enabled' in apidict:
apidict['mac_state'] = \
ON_STATE if apidict['mac_learning_enabled'] else OFF_STATE
pairs = apidict.get('allowed_address_pairs')
if pairs:
apidict = copy.deepcopy(apidict)
wrapped_pairs = [PortAllowedAddressPair(pair) for pair in pairs]
apidict['allowed_address_pairs'] = wrapped_pairs
super(Port, self).__init__(apidict)
class PortAllowedAddressPair(NeutronAPIDictWrapper):
"""Wrapper for neutron port allowed address pairs."""
def __init__(self, addr_pair):
super(PortAllowedAddressPair, self).__init__(addr_pair)
# Horizon references id property for table operations
self.id = addr_pair['ip_address']
class Profile(NeutronAPIDictWrapper):
"""Wrapper for neutron profiles."""
_attrs = ['profile_id', 'name', 'segment_type', 'segment_range',

View File

@ -0,0 +1,21 @@
# Copyright 2015, Alcatel-Lucent USA Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from openstack_dashboard.dashboards.project.networks.ports.extensions.\
allowed_address_pairs import forms as project_forms
class AddAllowedAddressPairForm(project_forms.AddAllowedAddressPairForm):
failure_url = 'horizon:admin:networks:ports:detail'

View File

@ -0,0 +1,25 @@
# Copyright 2015, Alcatel-Lucent USA Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from openstack_dashboard.dashboards.project.networks.ports.extensions.\
allowed_address_pairs import views as project_views
from openstack_dashboard.dashboards.admin.networks.ports.extensions.\
allowed_address_pairs import forms as admin_forms
class AddAllowedAddressPair(project_views.AddAllowedAddressPair):
form_class = admin_forms.AddAllowedAddressPairForm
submit_url = "horizon:admin:networks:ports:addallowedaddresspairs"
success_url = 'horizon:admin:networks:ports:detail'

View File

@ -14,6 +14,8 @@
from openstack_dashboard.dashboards.project.networks.ports \
import tabs as project_tabs
from openstack_dashboard.dashboards.project.networks.ports.extensions. \
allowed_address_pairs import tabs as addr_pairs_tabs
class OverviewTab(project_tabs.OverviewTab):
@ -21,4 +23,4 @@ class OverviewTab(project_tabs.OverviewTab):
class PortDetailTabs(project_tabs.PortDetailTabs):
tabs = (OverviewTab,)
tabs = (OverviewTab, addr_pairs_tabs.AllowedAddressPairsTab)

View File

@ -49,6 +49,9 @@ class NetworkPortTests(test.BaseAdminViewTests):
api.neutron.is_extension_supported(IsA(http.HttpRequest),
'mac-learning')\
.MultipleTimes().AndReturn(mac_learning)
api.neutron.is_extension_supported(IsA(http.HttpRequest),
'allowed-address-pairs') \
.MultipleTimes().AndReturn(False)
api.neutron.network_get(IsA(http.HttpRequest), network_id)\
.AndReturn(self.networks.first())
self.mox.ReplayAll()

View File

@ -15,10 +15,15 @@
from django.conf.urls import url
from openstack_dashboard.dashboards.admin.networks.ports import views
from openstack_dashboard.dashboards.admin.networks.ports.extensions. \
allowed_address_pairs import views as addr_pairs_views
PORTS = r'^(?P<port_id>[^/]+)/%s$'
urlpatterns = [
url(PORTS % 'detail', views.DetailView.as_view(), name='detail')
url(PORTS % 'detail', views.DetailView.as_view(), name='detail'),
url(PORTS % 'addallowedaddresspairs',
addr_pairs_views.AddAllowedAddressPair.as_view(),
name='addallowedaddresspairs'),
]

View File

@ -0,0 +1,75 @@
# Copyright 2015, Alcatel-Lucent USA Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import logging
from django.core.urlresolvers import reverse
from django.core import validators
from django.utils.translation import ugettext_lazy as _
from horizon import exceptions
from horizon import forms
from horizon import messages
from openstack_dashboard import api
LOG = logging.getLogger(__name__)
validate_mac = validators.RegexValidator(r'([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}',
_("Invalid MAC Address format"),
code="invalid_mac")
class AddAllowedAddressPairForm(forms.SelfHandlingForm):
ip = forms.IPField(label=_("IP Address or CIDR"),
help_text=_("A single IP Address or CIDR"),
version=forms.IPv4 | forms.IPv6,
mask=True)
mac = forms.CharField(label=_("MAC Address"),
help_text=_("A valid MAC Address"),
validators=[validate_mac],
required=False)
failure_url = 'horizon:project:networks:ports:detail'
def clean(self):
cleaned_data = super(AddAllowedAddressPairForm, self).clean()
if '/' not in self.data['ip']:
cleaned_data['ip'] = self.data['ip']
return cleaned_data
def handle(self, request, data):
port_id = self.initial['port_id']
try:
port = api.neutron.port_get(request, port_id)
current = port.get('allowed_address_pairs', [])
current = [pair.to_dict() for pair in current]
pair = {'ip_address': data['ip']}
if data['mac']:
pair['mac_address'] = data['mac']
current.append(pair)
port = api.neutron.port_update(request, port_id,
allowed_address_pairs=current)
msg = _('Port %s was successfully updated.') % port_id
messages.success(request, msg)
return port
except Exception as e:
LOG.error('Failed to update port %(port_id)s: %(reason)s',
{'port_id': port_id, 'reason': e})
msg = _('Failed to update port "%s".') % port_id
args = (self.initial.get('port_id'),)
redirect = reverse(self.failure_url, args=args)
exceptions.handle(request, msg, redirect=redirect)
return False

View File

@ -0,0 +1,96 @@
# Copyright 2015, Alcatel-Lucent USA Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import logging
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ungettext_lazy
from openstack_dashboard import api
from openstack_dashboard import policy
from horizon import exceptions
from horizon import tables
LOG = logging.getLogger(__name__)
class AddAllowedAddressPair(policy.PolicyTargetMixin, tables.LinkAction):
name = "AddAllowedAddressPair"
verbose_name = _("Add Allowed Address Pair")
url = "horizon:project:networks:ports:addallowedaddresspairs"
classes = ("ajax-modal",)
icon = "plus"
policy_rules = (("network", "update_port"),)
def get_link_url(self, port=None):
if port:
return reverse(self.url, args=(port.id,))
else:
return reverse(self.url, args=(self.table.kwargs.get('port_id'),))
class DeleteAllowedAddressPair(tables.DeleteAction):
@staticmethod
def action_present(count):
return ungettext_lazy(
u"Delete",
u"Delete",
count
)
@staticmethod
def action_past(count):
return ungettext_lazy(
u"Deleted address pair",
u"Deleted address pairs",
count
)
def delete(self, request, ip_address):
try:
port_id = self.table.kwargs['port_id']
port = api.neutron.port_get(request, port_id)
pairs = port.get('allowed_address_pairs', [])
pairs = [pair for pair in pairs
if pair['ip_address'] != ip_address]
pairs = [pair.to_dict() for pair in pairs]
api.neutron.port_update(request, port_id,
allowed_address_pairs=pairs)
except Exception as e:
LOG.error('Failed to update port %(port_id)s: %(reason)s',
{'port_id': port_id, 'reason': e})
redirect = reverse("horizon:project:networks:ports:detail",
args=(port_id,))
exceptions.handle(request, _('Failed to update port %s') % port_id,
redirect=redirect)
class AllowedAddressPairsTable(tables.DataTable):
IP = tables.Column("ip_address",
verbose_name=_("IP Address or CIDR"))
mac = tables.Column('mac_address', verbose_name=_("MAC Address"))
def get_object_display(self, address_pair):
return address_pair['ip_address']
class Meta(object):
name = "allowed_address_pairs"
verbose_name = _("Allowed Address Pairs")
row_actions = (DeleteAllowedAddressPair,)
table_actions = (AddAllowedAddressPair, DeleteAllowedAddressPair)

View File

@ -0,0 +1,51 @@
# Copyright 2015, Alcatel-Lucent USA Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import logging
from django.utils.translation import ugettext_lazy as _
from horizon import tabs
from openstack_dashboard import api
from openstack_dashboard.dashboards.project.networks.ports.extensions.\
allowed_address_pairs import tables as addr_pairs_tables
LOG = logging.getLogger(__name__)
class AllowedAddressPairsTab(tabs.TableTab):
table_classes = (addr_pairs_tables.AllowedAddressPairsTable,)
name = _("Allowed Address Pairs")
slug = "allowed_address_pairs"
template_name = "horizon/common/_detail_table.html"
def allowed(self, request):
port = self.tab_group.kwargs['port']
if not port or not port.get('port_security_enabled', True):
return False
try:
return api.neutron.is_extension_supported(request,
"allowed-address-pairs")
except Exception as e:
LOG.error("Failed to check if Neutron allowed-address-pairs "
"extension is supported: %s", e)
return False
def get_allowed_address_pairs_data(self):
port = self.tab_group.kwargs['port']
return port.get('allowed_address_pairs', [])

View File

@ -0,0 +1,51 @@
# Copyright 2015, Alcatel-Lucent USA Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import logging
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
from horizon import forms
from openstack_dashboard.dashboards.project.networks.ports.extensions.\
allowed_address_pairs import forms as addr_pairs_forms
LOG = logging.getLogger(__name__)
class AddAllowedAddressPair(forms.ModalFormView):
form_class = addr_pairs_forms.AddAllowedAddressPairForm
form_id = "addallowedaddresspair_form"
modal_header = _("Add allowed address pair")
template_name = 'project/networks/ports/add_addresspair.html'
context_object_name = 'port'
submit_label = _("Submit")
submit_url = "horizon:project:networks:ports:addallowedaddresspairs"
success_url = 'horizon:project:networks:ports:detail'
page_title = _("Add allowed address pair")
def get_success_url(self):
return reverse(self.success_url, args=(self.kwargs['port_id'],))
def get_context_data(self, **kwargs):
context = super(AddAllowedAddressPair, self).get_context_data(**kwargs)
context["port_id"] = self.kwargs['port_id']
context['submit_url'] = reverse(self.submit_url,
args=(self.kwargs['port_id'],))
return context
def get_initial(self):
return {'port_id': self.kwargs['port_id']}

View File

@ -16,6 +16,9 @@ from django.utils.translation import ugettext_lazy as _
from horizon import tabs
from openstack_dashboard.dashboards.project.networks.ports.extensions. \
allowed_address_pairs import tabs as addr_pairs_tabs
class OverviewTab(tabs.Tab):
name = _("Overview")
@ -29,4 +32,5 @@ class OverviewTab(tabs.Tab):
class PortDetailTabs(tabs.TabGroup):
slug = "port_details"
tabs = (OverviewTab,)
tabs = (OverviewTab, addr_pairs_tabs.AllowedAddressPairsTab)
sticky = True

View File

@ -13,6 +13,8 @@
# License for the specific language governing permissions and limitations
# under the License.
import copy
from django.core.urlresolvers import reverse
from django import http
@ -48,12 +50,12 @@ class NetworkPortTests(test.TestCase):
.AndReturn(self.ports.first())
api.neutron.is_extension_supported(IsA(http.HttpRequest),
'mac-learning')\
.AndReturn(mac_learning)
.MultipleTimes().AndReturn(mac_learning)
api.neutron.network_get(IsA(http.HttpRequest), network_id)\
.AndReturn(self.networks.first())
api.neutron.is_extension_supported(IsA(http.HttpRequest),
'mac-learning')\
.AndReturn(mac_learning)
'allowed-address-pairs')\
.MultipleTimes().AndReturn(False)
self.mox.ReplayAll()
res = self.client.get(reverse(DETAIL_URL, args=[port.id]))
@ -201,3 +203,123 @@ class NetworkPortTests(test.TestCase):
redir_url = reverse(NETWORKS_DETAIL_URL, args=[port.network_id])
self.assertRedirectsNoFollow(res, redir_url)
@test.create_stubs({api.neutron: ('port_get', 'network_get',
'is_extension_supported',)})
def test_allowed_address_pair_detail(self):
port = self.ports.first()
network = self.networks.first()
api.neutron.port_get(IsA(http.HttpRequest), port.id) \
.AndReturn(self.ports.first())
api.neutron.is_extension_supported(IsA(http.HttpRequest),
'allowed-address-pairs') \
.MultipleTimes().AndReturn(True)
api.neutron.is_extension_supported(IsA(http.HttpRequest),
'mac-learning') \
.MultipleTimes().AndReturn(False)
api.neutron.network_get(IsA(http.HttpRequest), network.id)\
.AndReturn(network)
self.mox.ReplayAll()
res = self.client.get(reverse('horizon:project:networks:ports:detail',
args=[port.id]))
self.assertTemplateUsed(res, 'horizon/common/_detail.html')
self.assertEqual(res.context['port'].id, port.id)
address_pairs = res.context['allowed_address_pairs_table'].data
self.assertItemsEqual(port.allowed_address_pairs, address_pairs)
@test.create_stubs({api.neutron: ('port_get', 'port_update')})
def test_port_add_allowed_address_pair(self):
detail_path = 'horizon:project:networks:ports:detail'
pre_port = self.ports.first()
post_port = copy.deepcopy(pre_port)
pair = {'ip_address': '179.0.0.201',
'mac_address': 'fa:16:4e:7a:7b:18'}
post_port['allowed_address_pairs'].insert(
1, api.neutron.PortAllowedAddressPair(pair))
api.neutron.port_get(IsA(http.HttpRequest), pre_port.id) \
.MultipleTimes().AndReturn(pre_port)
update_pairs = post_port['allowed_address_pairs']
update_pairs = [p.to_dict() for p in update_pairs]
params = {'allowed_address_pairs': update_pairs}
port_update = api.neutron.port_update(IsA(http.HttpRequest),
pre_port.id, **params)
port_update.AndReturn({'port': post_port})
self.mox.ReplayAll()
form_data = {'ip': pair['ip_address'], 'mac': pair['mac_address'],
'port_id': pre_port.id}
url = reverse('horizon:project:networks:ports:addallowedaddresspairs',
args=[pre_port.id])
res = self.client.post(url, form_data)
self.assertNoFormErrors(res)
detail_url = reverse(detail_path, args=[pre_port.id])
self.assertRedirectsNoFollow(res, detail_url)
self.assertMessageCount(success=1)
def test_port_add_allowed_address_pair_incorrect_mac(self):
pre_port = self.ports.first()
pair = {'ip_address': '179.0.0.201',
'mac_address': 'incorrect'}
form_data = {'ip': pair['ip_address'], 'mac': pair['mac_address'],
'port_id': pre_port.id}
url = reverse('horizon:project:networks:ports:addallowedaddresspairs',
args=[pre_port.id])
res = self.client.post(url, form_data)
self.assertFormErrors(res, 1)
self.assertContains(res, "Invalid MAC Address format")
def test_port_add_allowed_address_pair_incorrect_ip(self):
pre_port = self.ports.first()
pair = {'ip_address': 'incorrect',
'mac_address': 'fa:16:4e:7a:7b:18'}
form_data = {'ip': pair['ip_address'], 'mac': pair['mac_address'],
'port_id': pre_port.id}
url = reverse('horizon:project:networks:ports:addallowedaddresspairs',
args=[pre_port.id])
res = self.client.post(url, form_data)
self.assertFormErrors(res, 1)
self.assertContains(res, "Incorrect format for IP address")
@test.create_stubs({api.neutron: ('port_get', 'port_update',
'is_extension_supported',)})
def test_port_remove_allowed_address_pair(self):
detail_path = 'horizon:project:networks:ports:detail'
pre_port = self.ports.first()
post_port = copy.deepcopy(pre_port)
pair = post_port['allowed_address_pairs'].pop()
# Update will do get and update
api.neutron.port_get(IsA(http.HttpRequest), pre_port.id) \
.AndReturn(pre_port)
params = {'allowed_address_pairs': post_port['allowed_address_pairs']}
api.neutron.port_update(IsA(http.HttpRequest),
pre_port.id, **params) \
.AndReturn({'port': post_port})
# After update the detail page is loaded
api.neutron.is_extension_supported(IsA(http.HttpRequest),
'mac-learning') \
.MultipleTimes().AndReturn(False)
api.neutron.is_extension_supported(IsA(http.HttpRequest),
'allowed-address-pairs') \
.MultipleTimes().AndReturn(True)
api.neutron.port_get(IsA(http.HttpRequest), pre_port.id) \
.AndReturn(post_port)
self.mox.ReplayAll()
pair_ip = pair['ip_address']
form_data = {'action': 'allowed_address_pairs__delete__%s' % pair_ip}
url = reverse(detail_path, args=[pre_port.id])
res = self.client.post(url, form_data)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, url)
self.assertMessageCount(success=1)

View File

@ -15,10 +15,15 @@
from django.conf.urls import url
from openstack_dashboard.dashboards.project.networks.ports import views
from openstack_dashboard.dashboards.project.networks.ports.extensions. \
allowed_address_pairs import views as addr_pairs_views
PORTS = r'^(?P<port_id>[^/]+)/%s$'
urlpatterns = [
url(PORTS % 'detail', views.DetailView.as_view(), name='detail'),
url(PORTS % 'addallowedaddresspairs',
addr_pairs_views.AddAllowedAddressPair.as_view(),
name='addallowedaddresspairs')
]

View File

@ -34,7 +34,7 @@ STATUS_DICT = dict(project_tables.STATUS_DISPLAY_CHOICES)
VNIC_TYPES = dict(project_forms.VNIC_TYPES)
class DetailView(tabs.TabView):
class DetailView(tabs.TabbedTableView):
tab_group_class = project_tabs.PortDetailTabs
template_name = 'horizon/common/_detail.html'
page_title = "{{ port.name|default:port.id }}"

View File

@ -0,0 +1,9 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block modal-body-right %}
<h3>{% trans "Description:" %}</h3>
<p>
{% trans "Add an allowed address pair for this port. This will allow multiple MAC/IP address (range) pairs to pass through this port."%}
</p>
{% endblock %}

View File

@ -0,0 +1,7 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Add allowed address pair" %}{% endblock %}
{% block main %}
{% include 'project/networks/ports/_add_addresspair.html' %}
{% endblock %}

View File

@ -169,7 +169,10 @@ def data(TEST):
'status': 'ACTIVE',
'tenant_id': network_dict['tenant_id'],
'binding:vnic_type': 'normal',
'binding:host_id': 'host'}
'binding:host_id': 'host',
'allowed_address_pairs': [{'ip_address': '174.0.0.201',
'mac_address': 'fa:16:3e:7a:7b:18'}]
}
TEST.api_ports.add(port_dict)
TEST.ports.add(neutron.Port(port_dict))

View File

@ -0,0 +1,6 @@
---
features:
- The port-details page has a new tab for managing Allowed Address Pairs.
This tab and its features will only be available when this extension is
active in Neutron. The Allowed Address Pairs tab will enable creating,
deleting, and listing address pairs for the current port.