From dfb5a06d9dea49dbb3b215126e654aeda67a7821 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 24 Sep 2020 11:25:52 -0400 Subject: [PATCH 1/4] Introduce the RouteTarget model --- netbox/ipam/api/nested_serializers.py | 13 +++ netbox/ipam/api/serializers.py | 22 +++-- netbox/ipam/api/urls.py | 3 + netbox/ipam/api/views.py | 12 ++- netbox/ipam/constants.py | 1 + netbox/ipam/filters.py | 23 ++++- netbox/ipam/forms.py | 63 +++++++++++++- netbox/ipam/migrations/0041_routetarget.py | 34 ++++++++ netbox/ipam/models.py | 44 ++++++++++ netbox/ipam/tables.py | 22 ++++- netbox/ipam/urls.py | 13 ++- netbox/ipam/views.py | 52 +++++++++++- netbox/templates/inc/nav_menu.html | 9 ++ netbox/templates/ipam/routetarget.html | 98 ++++++++++++++++++++++ 14 files changed, 397 insertions(+), 12 deletions(-) create mode 100644 netbox/ipam/migrations/0041_routetarget.py create mode 100644 netbox/templates/ipam/routetarget.html diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index d40c9bb29..004ac070c 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -9,6 +9,7 @@ __all__ = [ 'NestedPrefixSerializer', 'NestedRIRSerializer', 'NestedRoleSerializer', + 'NestedRouteTargetSerializer', 'NestedServiceSerializer', 'NestedVLANGroupSerializer', 'NestedVLANSerializer', @@ -29,6 +30,18 @@ class NestedVRFSerializer(WritableNestedSerializer): fields = ['id', 'url', 'name', 'rd', 'display_name', 'prefix_count'] +# +# Route targets +# + +class NestedRouteTargetSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:routetarget-detail') + + class Meta: + model = models.RouteTarget + fields = ['id', 'url', 'name'] + + # # RIRs/aggregates # diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 074cba9d6..ffaefad6d 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -3,20 +3,17 @@ from collections import OrderedDict from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers -from rest_framework.reverse import reverse from rest_framework.validators import UniqueTogetherValidator from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer -from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from ipam.choices import * from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS -from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import ( - ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer, - get_serializer_for_model, + ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer, get_serializer_for_model, ) from virtualization.api.nested_serializers import NestedVirtualMachineSerializer from .nested_serializers import * @@ -40,6 +37,21 @@ class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): ] +# +# Route targets +# + +class RouteTargetSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:routetarget-detail') + tenant = NestedTenantSerializer(required=False, allow_null=True) + + class Meta: + model = RouteTarget + fields = [ + 'id', 'url', 'name', 'tenant', 'description', 'tags', 'custom_fields', 'created', 'last_updated', + ] + + # # RIRs/aggregates # diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index e297d6451..a8cbf7a29 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -8,6 +8,9 @@ router.APIRootView = views.IPAMRootView # VRFs router.register('vrfs', views.VRFViewSet) +# Route targets +router.register('route-targets', views.RouteTargetViewSet) + # RIRs router.register('rirs', views.RIRViewSet) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index dd0731bb8..69277a8ea 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -10,7 +10,7 @@ from rest_framework.routers import APIRootView from extras.api.views import CustomFieldModelViewSet from ipam import filters -from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF from utilities.api import ModelViewSet from utilities.constants import ADVISORY_LOCK_KEYS from utilities.utils import get_subquery @@ -38,6 +38,16 @@ class VRFViewSet(CustomFieldModelViewSet): filterset_class = filters.VRFFilterSet +# +# Route targets +# + +class RouteTargetViewSet(CustomFieldModelViewSet): + queryset = RouteTarget.objects.prefetch_related('tenant').prefetch_related('tags') + serializer_class = serializers.RouteTargetSerializer + filterset_class = filters.RouteTargetFilterSet + + # # RIRs # diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index 1ad355aec..e8825ad18 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -16,6 +16,7 @@ BGP_ASN_MAX = 2**32 - 1 # * Type 1 (32-bit IPv4 address : 16-bit integer) # * Type 2 (32-bit AS number : 16-bit integer) # 21 characters are sufficient to convey the longest possible string value (255.255.255.255:65535) +# Also used for RouteTargets VRF_RD_MAX_LENGTH = 21 diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 69453ea6c..6059b4330 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -13,7 +13,7 @@ from utilities.filters import ( ) from virtualization.models import VirtualMachine, VMInterface from .choices import * -from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF __all__ = ( @@ -22,6 +22,7 @@ __all__ = ( 'PrefixFilterSet', 'RIRFilterSet', 'RoleFilterSet', + 'RouteTargetFilterSet', 'ServiceFilterSet', 'VLANFilterSet', 'VLANGroupFilterSet', @@ -50,6 +51,26 @@ class VRFFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Create fields = ['id', 'name', 'rd', 'enforce_unique'] +class RouteTargetFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + tag = TagFilter() + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) + ) + + class Meta: + model = RouteTarget + fields = ['id', 'name'] + + class RIRFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index fd1dd00c6..ed6071756 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1,5 +1,4 @@ from django import forms -from django.core.validators import MaxValueValidator, MinValueValidator from dcim.models import Device, Interface, Rack, Region, Site from extras.forms import ( @@ -16,7 +15,7 @@ from utilities.forms import ( from virtualization.models import Cluster, VirtualMachine, VMInterface from .choices import * from .constants import * -from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([ (i, i) for i in range(PREFIX_LENGTH_MIN, PREFIX_LENGTH_MAX + 1) @@ -98,6 +97,66 @@ class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): tag = TagFilterField(model) +# +# Route targets +# + +class RouteTargetForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = RouteTarget + fields = [ + 'name', 'description', 'tenant_group', 'tenant', 'tags', + ] + + +class RouteTargetCSVForm(CustomFieldModelCSVForm): + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) + + class Meta: + model = RouteTarget + fields = RouteTarget.csv_headers + + +class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + widget=forms.MultipleHiddenInput() + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = [ + 'tenant', 'description', + ] + + +class RouteTargetFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): + model = RouteTarget + field_order = ['q', 'name', 'tenant_group', 'tenant'] + q = forms.CharField( + required=False, + label='Search' + ) + tag = TagFilterField(model) + + # # RIRs # diff --git a/netbox/ipam/migrations/0041_routetarget.py b/netbox/ipam/migrations/0041_routetarget.py new file mode 100644 index 000000000..d2e800be2 --- /dev/null +++ b/netbox/ipam/migrations/0041_routetarget.py @@ -0,0 +1,34 @@ +# Generated by Django 3.1 on 2020-09-24 15:19 + +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0010_custom_field_data'), + ('extras', '0052_delete_customfieldchoice_customfieldvalue'), + ('ipam', '0040_service_drop_port'), + ] + + operations = [ + migrations.CreateModel( + name='RouteTarget', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('name', models.CharField(max_length=21, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='route_targets', to='tenancy.tenant')), + ], + options={ + 'ordering': ['name'], + }, + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index c11f0e296..f743fe5b0 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -107,6 +107,50 @@ class VRF(ChangeLoggedModel, CustomFieldModel): return self.name +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class RouteTarget(ChangeLoggedModel, CustomFieldModel): + """ + A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364. + """ + name = models.CharField( + max_length=VRF_RD_MAX_LENGTH, # Same format options as VRF RD (RFC 4360 section 4) + unique=True, + help_text='Route target value (formatted in accordance with RFC 4360)' + ) + description = models.CharField( + max_length=200, + blank=True + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='route_targets', + blank=True, + null=True + ) + tags = TaggableManager(through=TaggedItem) + + objects = RestrictedQuerySet.as_manager() + + csv_headers = ['name', 'description', 'tenant'] + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('ipam:routetarget', args=[self.pk]) + + def to_csv(self): + return ( + self.name, + self.description, + self.tenant.name if self.tenant else None, + ) + + class RIR(ChangeLoggedModel): """ A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 3e89ece64..6a76b5c91 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -5,7 +5,7 @@ from dcim.models import Interface from tenancy.tables import COL_TENANT from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, TagColumn, ToggleColumn from virtualization.models import VMInterface -from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF RIR_UTILIZATION = """
@@ -176,6 +176,26 @@ class VRFTable(BaseTable): default_columns = ('pk', 'name', 'rd', 'tenant', 'description') +# +# Route targets +# + +class RouteTargetTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn() + tenant = tables.TemplateColumn( + template_code=COL_TENANT + ) + tags = TagColumn( + url_name='ipam:vrf_list' + ) + + class Meta(BaseTable.Meta): + model = RouteTarget + fields = ('pk', 'name', 'tenant', 'description', 'tags') + default_columns = ('pk', 'name', 'tenant', 'description') + + # # RIRs # diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 533335816..9b0dc581b 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -2,7 +2,7 @@ from django.urls import path from extras.views import ObjectChangeLogView from . import views -from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF app_name = 'ipam' urlpatterns = [ @@ -18,6 +18,17 @@ urlpatterns = [ path('vrfs//delete/', views.VRFDeleteView.as_view(), name='vrf_delete'), path('vrfs//changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}), + # Route targets + path('route-targets/', views.RouteTargetListView.as_view(), name='routetarget_list'), + path('route-targets/add/', views.RouteTargetEditView.as_view(), name='routetarget_add'), + path('route-targets/import/', views.RouteTargetBulkImportView.as_view(), name='routetarget_import'), + path('route-targets/edit/', views.RouteTargetBulkEditView.as_view(), name='routetarget_bulk_edit'), + path('route-targets/delete/', views.RouteTargetBulkDeleteView.as_view(), name='routetarget_bulk_delete'), + path('route-targets//', views.RouteTargetView.as_view(), name='routetarget'), + path('route-targets//edit/', views.RouteTargetEditView.as_view(), name='routetarget_edit'), + path('route-targets//delete/', views.RouteTargetDeleteView.as_view(), name='routetarget_delete'), + path('route-targets//changelog/', ObjectChangeLogView.as_view(), name='routetarget_changelog', kwargs={'model': RouteTarget}), + # RIRs path('rirs/', views.RIRListView.as_view(), name='rir_list'), path('rirs/add/', views.RIREditView.as_view(), name='rir_add'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 64e71b69b..240bfedd3 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -16,7 +16,7 @@ from virtualization.models import VirtualMachine, VMInterface from . import filters, forms, tables from .choices import * from .constants import * -from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF from .utils import add_available_ipaddresses, add_available_prefixes, add_available_vlans @@ -74,6 +74,56 @@ class VRFBulkDeleteView(BulkDeleteView): table = tables.VRFTable +# +# Route targets +# + +class RouteTargetListView(ObjectListView): + queryset = RouteTarget.objects.prefetch_related('tenant') + filterset = filters.RouteTargetFilterSet + filterset_form = forms.RouteTargetFilterForm + table = tables.RouteTargetTable + + +class RouteTargetView(ObjectView): + queryset = RouteTarget.objects.all() + + def get(self, request, pk): + routetarget = get_object_or_404(self.queryset, pk=pk) + + return render(request, 'ipam/routetarget.html', { + 'routetarget': routetarget, + }) + + +class RouteTargetEditView(ObjectEditView): + queryset = RouteTarget.objects.all() + model_form = forms.RouteTargetForm + + +class RouteTargetDeleteView(ObjectDeleteView): + queryset = RouteTarget.objects.all() + + +class RouteTargetBulkImportView(BulkImportView): + queryset = RouteTarget.objects.all() + model_form = forms.RouteTargetCSVForm + table = tables.RouteTargetTable + + +class RouteTargetBulkEditView(BulkEditView): + queryset = RouteTarget.objects.prefetch_related('tenant') + filterset = filters.RouteTargetFilterSet + table = tables.RouteTargetTable + form = forms.RouteTargetBulkEditForm + + +class RouteTargetBulkDeleteView(BulkDeleteView): + queryset = RouteTarget.objects.prefetch_related('tenant') + filterset = filters.RouteTargetFilterSet + table = tables.RouteTargetTable + + # # RIRs # diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index bf3d349cc..12c579bdd 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -331,6 +331,15 @@ {% endif %} VRFs + + {% if perms.ipam.add_routetarget %} +
+ + +
+ {% endif %} + Route Targets +
  • diff --git a/netbox/templates/ipam/routetarget.html b/netbox/templates/ipam/routetarget.html new file mode 100644 index 000000000..d891f9241 --- /dev/null +++ b/netbox/templates/ipam/routetarget.html @@ -0,0 +1,98 @@ +{% extends 'base.html' %} +{% load buttons %} +{% load custom_links %} +{% load helpers %} +{% load plugins %} + +{% block header %} +
    +
    + +
    +
    +
    +
    + + + + +
    +
    +
    +
    +
    + {% plugin_buttons routetarget %} + {% if perms.ipam.add_routetarget %} + {% clone_button routetarget %} + {% endif %} + {% if perms.ipam.change_routetarget %} + {% edit_button routetarget %} + {% endif %} + {% if perms.ipam.delete_routetarget %} + {% delete_button routetarget %} + {% endif %} +
    +

    {% block title %}Route target {{ routetarget }}{% endblock %}

    + {% include 'inc/created_updated.html' with obj=routetarget %} +
    + {% custom_links routetarget %} +
    + +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Route Target +
    + + + + + + + + + + + + + +
    Name{{ routetarget.name }}
    Tenant + {% if routetarget.tenant %} + {{ routetarget.tenant }} + {% else %} + None + {% endif %} +
    Description{{ vrf.description|placeholder }}
    +
    + {% include 'extras/inc/tags_panel.html' with tags=routetarget.tags.all url='ipam:routetarget_list' %} + {% plugin_left_page routetarget %} +
    +
    + {% include 'inc/custom_fields_panel.html' with obj=routetarget %} + {% plugin_right_page routetarget %} +
    +
    +
    +
    + {% plugin_full_width_page routetarget %} +
    +
    +{% endblock %} From f684d07c616f567855dbab32d557d76b4c61e986 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 24 Sep 2020 12:09:28 -0400 Subject: [PATCH 2/4] Model import/export route targets on VRFs --- netbox/ipam/api/serializers.py | 6 ++- netbox/ipam/api/views.py | 4 +- netbox/ipam/filters.py | 44 ++++++++++++++++++++++ netbox/ipam/forms.py | 35 +++++++++++++++-- netbox/ipam/migrations/0041_routetarget.py | 10 +++++ netbox/ipam/models.py | 10 +++++ netbox/ipam/views.py | 22 +++++++++++ netbox/templates/ipam/routetarget.html | 4 +- netbox/templates/ipam/vrf.html | 4 +- netbox/templates/ipam/vrf_edit.html | 7 ++++ 10 files changed, 138 insertions(+), 8 deletions(-) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index ffaefad6d..0022dbd73 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -26,14 +26,16 @@ from .nested_serializers import * class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail') tenant = NestedTenantSerializer(required=False, allow_null=True) + import_targets = NestedRouteTargetSerializer(required=False, allow_null=True, many=True) + export_targets = NestedRouteTargetSerializer(required=False, allow_null=True, many=True) ipaddress_count = serializers.IntegerField(read_only=True) prefix_count = serializers.IntegerField(read_only=True) class Meta: model = VRF fields = [ - 'id', 'url', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'tags', 'display_name', - 'custom_fields', 'created', 'last_updated', 'ipaddress_count', 'prefix_count', + 'id', 'url', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', + 'tags', 'display_name', 'custom_fields', 'created', 'last_updated', 'ipaddress_count', 'prefix_count', ] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 69277a8ea..449ef3245 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -30,7 +30,9 @@ class IPAMRootView(APIRootView): # class VRFViewSet(CustomFieldModelViewSet): - queryset = VRF.objects.prefetch_related('tenant').prefetch_related('tags').annotate( + queryset = VRF.objects.prefetch_related('tenant').prefetch_related( + 'import_targets', 'export_targets', 'tags' + ).annotate( ipaddress_count=get_subquery(IPAddress, 'vrf'), prefix_count=get_subquery(Prefix, 'vrf') ).order_by(*VRF._meta.ordering) diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 6059b4330..0cbbd3f78 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -35,6 +35,28 @@ class VRFFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Create method='search', label='Search', ) + import_target_id = django_filters.ModelMultipleChoiceFilter( + field_name='import_targets', + queryset=RouteTarget.objects.all(), + label='Import target', + ) + import_target = django_filters.ModelMultipleChoiceFilter( + field_name='import_targets__name', + queryset=RouteTarget.objects.all(), + to_field_name='name', + label='Import target (name)', + ) + export_target_id = django_filters.ModelMultipleChoiceFilter( + field_name='export_targets', + queryset=RouteTarget.objects.all(), + label='Export target', + ) + export_target = django_filters.ModelMultipleChoiceFilter( + field_name='export_targets__name', + queryset=RouteTarget.objects.all(), + to_field_name='name', + label='Export target (name)', + ) tag = TagFilter() def search(self, queryset, name, value): @@ -56,6 +78,28 @@ class RouteTargetFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet method='search', label='Search', ) + importing_vrf_id = django_filters.ModelMultipleChoiceFilter( + field_name='importing_vrfs', + queryset=VRF.objects.all(), + label='Importing VRF', + ) + importing_vrf = django_filters.ModelMultipleChoiceFilter( + field_name='importing_vrfs__rd', + queryset=VRF.objects.all(), + to_field_name='rd', + label='Import VRF (RD)', + ) + exporting_vrf_id = django_filters.ModelMultipleChoiceFilter( + field_name='exporting_vrfs', + queryset=VRF.objects.all(), + label='Exporting VRF', + ) + exporting_vrf = django_filters.ModelMultipleChoiceFilter( + field_name='exporting_vrfs__rd', + queryset=VRF.objects.all(), + to_field_name='rd', + label='Export VRF (RD)', + ) tag = TagFilter() def search(self, queryset, name, value): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index ed6071756..714279859 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -31,6 +31,14 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([ # class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + import_targets = DynamicModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + required=False + ) + export_targets = DynamicModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + required=False + ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -39,7 +47,8 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = VRF fields = [ - 'name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant', 'tags', + 'name', 'rd', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tenant_group', 'tenant', + 'tags', ] labels = { 'rd': "RD", @@ -89,11 +98,21 @@ class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = VRF - field_order = ['q', 'tenant_group', 'tenant'] + field_order = ['q', 'import_target', 'export_target', 'tenant_group', 'tenant'] q = forms.CharField( required=False, label='Search' ) + import_target = DynamicModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + to_field_name='name', + required=False + ) + export_target = DynamicModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + to_field_name='name', + required=False + ) tag = TagFilterField(model) @@ -149,11 +168,21 @@ class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulk class RouteTargetFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = RouteTarget - field_order = ['q', 'name', 'tenant_group', 'tenant'] + field_order = ['q', 'name', 'tenant_group', 'tenant', 'importing_vrfs', 'exporting_vrfs'] q = forms.CharField( required=False, label='Search' ) + importing_vrf_id = DynamicModelMultipleChoiceField( + queryset=VRF.objects.all(), + required=False, + label='Imported by VRF' + ) + exporting_vrf_id = DynamicModelMultipleChoiceField( + queryset=VRF.objects.all(), + required=False, + label='Exported by VRF' + ) tag = TagFilterField(model) diff --git a/netbox/ipam/migrations/0041_routetarget.py b/netbox/ipam/migrations/0041_routetarget.py index d2e800be2..9cc37b742 100644 --- a/netbox/ipam/migrations/0041_routetarget.py +++ b/netbox/ipam/migrations/0041_routetarget.py @@ -31,4 +31,14 @@ class Migration(migrations.Migration): 'ordering': ['name'], }, ), + migrations.AddField( + model_name='vrf', + name='export_targets', + field=models.ManyToManyField(blank=True, related_name='exporting_vrfs', to='ipam.RouteTarget'), + ), + migrations.AddField( + model_name='vrf', + name='import_targets', + field=models.ManyToManyField(blank=True, related_name='importing_vrfs', to='ipam.RouteTarget'), + ), ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index f743fe5b0..f7e4d9cf4 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -71,6 +71,16 @@ class VRF(ChangeLoggedModel, CustomFieldModel): max_length=200, blank=True ) + import_targets = models.ManyToManyField( + to='ipam.RouteTarget', + related_name='importing_vrfs', + blank=True + ) + export_targets = models.ManyToManyField( + to='ipam.RouteTarget', + related_name='exporting_vrfs', + blank=True + ) tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 240bfedd3..bc3e4b69b 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -39,9 +39,20 @@ class VRFView(ObjectView): vrf = get_object_or_404(self.queryset, pk=pk) prefix_count = Prefix.objects.restrict(request.user, 'view').filter(vrf=vrf).count() + import_targets_table = tables.RouteTargetTable( + vrf.import_targets.prefetch_related('tenant'), + orderable=False + ) + export_targets_table = tables.RouteTargetTable( + vrf.export_targets.prefetch_related('tenant'), + orderable=False + ) + return render(request, 'ipam/vrf.html', { 'vrf': vrf, 'prefix_count': prefix_count, + 'import_targets_table': import_targets_table, + 'export_targets_table': export_targets_table, }) @@ -91,8 +102,19 @@ class RouteTargetView(ObjectView): def get(self, request, pk): routetarget = get_object_or_404(self.queryset, pk=pk) + importing_vrfs_table = tables.VRFTable( + routetarget.importing_vrfs.prefetch_related('tenant'), + orderable=False + ) + exporting_vrfs_table = tables.VRFTable( + routetarget.exporting_vrfs.prefetch_related('tenant'), + orderable=False + ) + return render(request, 'ipam/routetarget.html', { 'routetarget': routetarget, + 'importing_vrfs_table': importing_vrfs_table, + 'exporting_vrfs_table': exporting_vrfs_table, }) diff --git a/netbox/templates/ipam/routetarget.html b/netbox/templates/ipam/routetarget.html index d891f9241..2271a8b39 100644 --- a/netbox/templates/ipam/routetarget.html +++ b/netbox/templates/ipam/routetarget.html @@ -83,10 +83,12 @@
    {% include 'extras/inc/tags_panel.html' with tags=routetarget.tags.all url='ipam:routetarget_list' %} + {% include 'inc/custom_fields_panel.html' with obj=routetarget %} {% plugin_left_page routetarget %}
    - {% include 'inc/custom_fields_panel.html' with obj=routetarget %} + {% include 'panel_table.html' with table=importing_vrfs_table heading="Importing VRFs" %} + {% include 'panel_table.html' with table=exporting_vrfs_table heading="Exporting VRFs" %} {% plugin_right_page routetarget %}
    diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index 6fb6d725f..ef77b5dea 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -99,10 +99,12 @@ {% include 'extras/inc/tags_panel.html' with tags=vrf.tags.all url='ipam:vrf_list' %} + {% include 'inc/custom_fields_panel.html' with obj=vrf %} {% plugin_left_page vrf %}
    - {% include 'inc/custom_fields_panel.html' with obj=vrf %} + {% include 'panel_table.html' with table=import_targets_table heading="Import Route Targets" %} + {% include 'panel_table.html' with table=export_targets_table heading="Export Route Targets" %} {% plugin_right_page vrf %}
    diff --git a/netbox/templates/ipam/vrf_edit.html b/netbox/templates/ipam/vrf_edit.html index a2ff51d9b..41b80bca9 100644 --- a/netbox/templates/ipam/vrf_edit.html +++ b/netbox/templates/ipam/vrf_edit.html @@ -11,6 +11,13 @@ {% render_field form.description %} +
    +
    Route Targets
    +
    + {% render_field form.import_targets %} + {% render_field form.export_targets %} +
    +
    Tenancy
    From 47fd9cab1cc84dbaf76e7efd92061aa9581a84ed Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 24 Sep 2020 13:51:17 -0400 Subject: [PATCH 3/4] Add tests for route targets; extend VRF tests --- netbox/ipam/tests/test_api.py | 31 +++++++- netbox/ipam/tests/test_filters.py | 115 +++++++++++++++++++++++++++++- netbox/ipam/tests/test_views.py | 42 ++++++++++- 3 files changed, 185 insertions(+), 3 deletions(-) diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 2cc24b6ae..db98713d0 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -6,7 +6,7 @@ from rest_framework import status from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from ipam.choices import * -from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF from utilities.testing import APITestCase, APIViewTestCases, disable_warnings @@ -52,6 +52,35 @@ class VRFTest(APIViewTestCases.APIViewTestCase): VRF.objects.bulk_create(vrfs) +class RouteTargetTest(APIViewTestCases.APIViewTestCase): + model = RouteTarget + brief_fields = ['id', 'name', 'url'] + create_data = [ + { + 'name': '65000:1004', + }, + { + 'name': '65000:1005', + }, + { + 'name': '65000:1006', + }, + ] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + route_targets = ( + RouteTarget(name='65000:1001'), + RouteTarget(name='65000:1002'), + RouteTarget(name='65000:1003'), + ) + RouteTarget.objects.bulk_create(route_targets) + + class RIRTest(APIViewTestCases.APIViewTestCase): model = RIR brief_fields = ['aggregate_count', 'id', 'name', 'slug', 'url'] diff --git a/netbox/ipam/tests/test_filters.py b/netbox/ipam/tests/test_filters.py index 1ecf5a486..aa607eb6b 100644 --- a/netbox/ipam/tests/test_filters.py +++ b/netbox/ipam/tests/test_filters.py @@ -3,7 +3,7 @@ from django.test import TestCase from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Region, Site from ipam.choices import * from ipam.filters import * -from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF from virtualization.models import Cluster, ClusterType, VirtualMachine, VMInterface from tenancy.models import Tenant, TenantGroup @@ -15,6 +15,13 @@ class VRFTestCase(TestCase): @classmethod def setUpTestData(cls): + route_targets = ( + RouteTarget(name='65000:1001'), + RouteTarget(name='65000:1002'), + RouteTarget(name='65000:1003'), + ) + RouteTarget.objects.bulk_create(route_targets) + tenant_groups = ( TenantGroup(name='Tenant group 1', slug='tenant-group-1'), TenantGroup(name='Tenant group 2', slug='tenant-group-2'), @@ -39,6 +46,12 @@ class VRFTestCase(TestCase): VRF(name='VRF 6', rd='65000:600', tenant=tenants[2], enforce_unique=True), ) VRF.objects.bulk_create(vrfs) + vrfs[0].import_targets.add(route_targets[0]) + vrfs[0].export_targets.add(route_targets[0]) + vrfs[1].import_targets.add(route_targets[1]) + vrfs[1].export_targets.add(route_targets[1]) + vrfs[2].import_targets.add(route_targets[2]) + vrfs[2].export_targets.add(route_targets[2]) def test_id(self): params = {'id': self.queryset.values_list('pk', flat=True)[:2]} @@ -58,6 +71,20 @@ class VRFTestCase(TestCase): params = {'enforce_unique': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_import_target(self): + route_targets = RouteTarget.objects.all()[:2] + params = {'import_target_id': [route_targets[0].pk, route_targets[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'import_target': [route_targets[0].name, route_targets[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_export_target(self): + route_targets = RouteTarget.objects.all()[:2] + params = {'export_target_id': [route_targets[0].pk, route_targets[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'export_target': [route_targets[0].name, route_targets[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_tenant(self): tenants = Tenant.objects.all()[:2] params = {'tenant_id': [tenants[0].pk, tenants[1].pk]} @@ -73,6 +100,92 @@ class VRFTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) +class RouteTargetTestCase(TestCase): + queryset = RouteTarget.objects.all() + filterset = RouteTargetFilterSet + + @classmethod + def setUpTestData(cls): + + tenant_groups = ( + TenantGroup(name='Tenant group 1', slug='tenant-group-1'), + TenantGroup(name='Tenant group 2', slug='tenant-group-2'), + TenantGroup(name='Tenant group 3', slug='tenant-group-3'), + ) + for tenantgroup in tenant_groups: + tenantgroup.save() + + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]), + Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]), + Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]), + ) + Tenant.objects.bulk_create(tenants) + + route_targets = ( + RouteTarget(name='65000:1001', tenant=tenants[0]), + RouteTarget(name='65000:1002', tenant=tenants[0]), + RouteTarget(name='65000:1003', tenant=tenants[0]), + RouteTarget(name='65000:1004', tenant=tenants[0]), + RouteTarget(name='65000:2001', tenant=tenants[1]), + RouteTarget(name='65000:2002', tenant=tenants[1]), + RouteTarget(name='65000:2003', tenant=tenants[1]), + RouteTarget(name='65000:2004', tenant=tenants[1]), + RouteTarget(name='65000:3001', tenant=tenants[2]), + RouteTarget(name='65000:3002', tenant=tenants[2]), + RouteTarget(name='65000:3003', tenant=tenants[2]), + RouteTarget(name='65000:3004', tenant=tenants[2]), + ) + RouteTarget.objects.bulk_create(route_targets) + + vrfs = ( + VRF(name='VRF 1', rd='65000:100'), + VRF(name='VRF 2', rd='65000:200'), + VRF(name='VRF 3', rd='65000:300'), + ) + VRF.objects.bulk_create(vrfs) + vrfs[0].import_targets.add(route_targets[0], route_targets[1]) + vrfs[0].export_targets.add(route_targets[2], route_targets[3]) + vrfs[1].import_targets.add(route_targets[4], route_targets[5]) + vrfs[1].export_targets.add(route_targets[6], route_targets[7]) + + def test_id(self): + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_name(self): + params = {'name': ['65000:1001', '65000:1002', '65000:1003']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_importing_vrf(self): + vrfs = VRF.objects.all()[:2] + params = {'importing_vrf_id': [vrfs[0].pk, vrfs[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'importing_vrf': [vrfs[0].rd, vrfs[1].rd]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_exporting_vrf(self): + vrfs = VRF.objects.all()[:2] + params = {'exporting_vrf_id': [vrfs[0].pk, vrfs[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'exporting_vrf': [vrfs[0].rd, vrfs[1].rd]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_tenant(self): + tenants = Tenant.objects.all()[:2] + params = {'tenant_id': [tenants[0].pk, tenants[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + params = {'tenant': [tenants[0].slug, tenants[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + + def test_tenant_group(self): + tenant_groups = TenantGroup.objects.all()[:2] + params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + + class RIRTestCase(TestCase): queryset = RIR.objects.all() filterset = RIRFilterSet diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index fc595ac9c..db96bb896 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -4,7 +4,7 @@ from netaddr import IPNetwork from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from ipam.choices import * -from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF from tenancy.models import Tenant from utilities.testing import ViewTestCases @@ -52,6 +52,46 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +class RouteTargetTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = RouteTarget + + @classmethod + def setUpTestData(cls): + + tenants = ( + Tenant(name='Tenant A', slug='tenant-a'), + Tenant(name='Tenant B', slug='tenant-b'), + ) + Tenant.objects.bulk_create(tenants) + + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + + route_targets = ( + RouteTarget(name='65000:1001', tenant=tenants[0]), + RouteTarget(name='65000:1002', tenant=tenants[1]), + RouteTarget(name='65000:1003'), + ) + RouteTarget.objects.bulk_create(route_targets) + + cls.form_data = { + 'name': '65000:100', + 'description': 'A new route target', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "name,tenant,description", + "65000:1004,Tenant A,Foo", + "65000:1005,Tenant B,Bar", + "65000:1006,,No tenant", + ) + + cls.bulk_edit_data = { + 'tenant': tenants[1].pk, + 'description': 'New description', + } + + class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = RIR From cca217388679680b37a830d7f0649c22078acc6f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 24 Sep 2020 14:18:08 -0400 Subject: [PATCH 4/4] Documentation for #259 --- docs/core-functionality/ipam.md | 1 + docs/models/ipam/routetarget.md | 5 +++++ docs/models/ipam/vrf.md | 2 ++ docs/release-notes/version-2.10.md | 4 ++++ 4 files changed, 12 insertions(+) create mode 100644 docs/models/ipam/routetarget.md diff --git a/docs/core-functionality/ipam.md b/docs/core-functionality/ipam.md index e5ab22f19..dd6eee77b 100644 --- a/docs/core-functionality/ipam.md +++ b/docs/core-functionality/ipam.md @@ -15,3 +15,4 @@ --- {!docs/models/ipam/vrf.md!} +{!docs/models/ipam/routetarget.md!} diff --git a/docs/models/ipam/routetarget.md b/docs/models/ipam/routetarget.md new file mode 100644 index 000000000..b71e96904 --- /dev/null +++ b/docs/models/ipam/routetarget.md @@ -0,0 +1,5 @@ +# Route Targets + +A route target is a particular type of [extended BGP community](https://tools.ietf.org/html/rfc4360#section-4) used to control the redistribution of routes among VRF tables in a network. Route targets can be assigned to individual VRFs in NetBox as import or export targets (or both) to model this exchange in an L3VPN. Each route target must be given a unique name, which should be in a format prescribed by [RFC 4364](https://tools.ietf.org/html/rfc4364#section-4.2), similar to a VR route distinguisher. + +Each route target can optionally be assigned to a tenant, and may have tags assigned to it. diff --git a/docs/models/ipam/vrf.md b/docs/models/ipam/vrf.md index 599d05c82..392141fdd 100644 --- a/docs/models/ipam/vrf.md +++ b/docs/models/ipam/vrf.md @@ -10,3 +10,5 @@ By default, NetBox will allow duplicate prefixes to be assigned to a VRF. This b !!! note Enforcement of unique IP space can be toggled for global table (non-VRF prefixes) using the `ENFORCE_GLOBAL_UNIQUE` configuration setting. + +Each VRF may have one or more import and/or export route targets applied to it. Route targets are used to control the exchange of routes (prefixes) among VRFs in L3VPNs. diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index bd54006fb..31437bacb 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -6,6 +6,10 @@ ### New Features +#### Route Targets ([#259](https://github.com/netbox-community/netbox/issues/259)) + +This release introduces support for model L3VPN route targets, which can be used to control the redistribution of routing information among VRFs. Each VRF may be assigned one or more route targets in the import or export direction (or both). Like VRFs, route targets may be assigned to tenants and may have tags applied to them. + #### REST API Bulk Deletion ([#3436](https://github.com/netbox-community/netbox/issues/3436)) The REST API now supports the bulk deletion of objects of the same type in a single request. Send a `DELETE` HTTP request to the list to the model's list endpoint (e.g. `/api/dcim/sites/`) with a list of JSON objects specifying the numeric ID of each object to be deleted. For example, to delete sites with IDs 10, 11, and 12, issue the following request: