From 7994073687e8b02828d881c53ffad0b2e271515e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 27 Feb 2023 16:36:05 -0500 Subject: [PATCH] Closes #8550: Implement ASN ranges (#11835) * Move ASN to a separate module * Move ASNField from dcim to ipam * Introduce ASNRange model * Add relationship from ASN to ASNRange * Add an available-asns API endpoint * Add RIR assignment for ASNRange * Add standard tests * Move child ASNs to a tabbed view * Remove FK on ASN to ASNRange * Add tests for provisioning available ASNs * Add docs for ASNRange --- docs/models/ipam/asn.md | 4 +- docs/models/ipam/asnrange.md | 21 +++ docs/release-notes/version-3.5.md | 4 + mkdocs.yml | 1 + netbox/circuits/migrations/0001_squashed.py | 4 +- netbox/dcim/fields.py | 18 --- netbox/dcim/migrations/0001_squashed.py | 3 +- netbox/ipam/api/nested_serializers.py | 13 ++ netbox/ipam/api/serializers.py | 38 ++++- netbox/ipam/api/urls.py | 29 +--- netbox/ipam/api/views.py | 74 +++++++++ netbox/ipam/constants.py | 4 - netbox/ipam/fields.py | 27 ++++ netbox/ipam/filtersets.py | 24 +++ netbox/ipam/forms/bulk_edit.py | 25 ++- netbox/ipam/forms/bulk_import.py | 19 +++ netbox/ipam/forms/filtersets.py | 22 +++ netbox/ipam/forms/model_forms.py | 19 +++ netbox/ipam/graphql/schema.py | 3 + netbox/ipam/graphql/types.py | 10 +- netbox/ipam/migrations/0053_asn_model.py | 6 +- netbox/ipam/migrations/0064_asnrange.py | 41 +++++ netbox/ipam/models/__init__.py | 2 + netbox/ipam/models/asns.py | 137 +++++++++++++++++ netbox/ipam/models/ip.py | 61 -------- netbox/ipam/search.py | 8 + netbox/ipam/tables/__init__.py | 1 + netbox/ipam/tables/asn.py | 77 ++++++++++ netbox/ipam/tables/ip.py | 42 ----- netbox/ipam/tests/test_api.py | 154 ++++++++++++++++--- netbox/ipam/tests/test_filtersets.py | 160 +++++++++++++++----- netbox/ipam/tests/test_views.py | 101 +++++++++--- netbox/ipam/urls.py | 8 + netbox/ipam/utils.py | 2 +- netbox/ipam/views.py | 76 +++++++++- netbox/netbox/navigation/menu.py | 1 + netbox/templates/ipam/asn.html | 3 + netbox/templates/ipam/asnrange.html | 57 +++++++ netbox/templates/ipam/asnrange/asns.html | 36 +++++ netbox/templates/ipam/asnrange/base.html | 6 + netbox/utilities/constants.py | 1 + 41 files changed, 1096 insertions(+), 246 deletions(-) create mode 100644 docs/models/ipam/asnrange.md create mode 100644 netbox/ipam/migrations/0064_asnrange.py create mode 100644 netbox/ipam/models/asns.py create mode 100644 netbox/ipam/tables/asn.py create mode 100644 netbox/templates/ipam/asnrange.html create mode 100644 netbox/templates/ipam/asnrange/asns.html create mode 100644 netbox/templates/ipam/asnrange/base.html diff --git a/docs/models/ipam/asn.md b/docs/models/ipam/asn.md index e34790406..8de3cfd93 100644 --- a/docs/models/ipam/asn.md +++ b/docs/models/ipam/asn.md @@ -1,8 +1,8 @@ -# ASN +# ASNs An Autonomous System Number (ASN) is a numeric identifier used in the BGP protocol to identify which [autonomous system](https://en.wikipedia.org/wiki/Autonomous_system_%28Internet%29) a particular prefix is originating and transiting through. NetBox support both 32- and 64- ASNs. -ASNs must be globally unique within NetBox, must each may be assigned to multiple [sites](../dcim/site.md). +ASNs must be globally unique within NetBox, and may be allocated from within a [defined range](./asnrange.md). Each ASN may be assigned to multiple [sites](../dcim/site.md). ## Fields diff --git a/docs/models/ipam/asnrange.md b/docs/models/ipam/asnrange.md new file mode 100644 index 000000000..30d2f49c3 --- /dev/null +++ b/docs/models/ipam/asnrange.md @@ -0,0 +1,21 @@ +# ASN Ranges + +Ranges can be defined to group [AS numbers](./asn.md) numerically and to facilitate their automatic provisioning. Each range must be assigned to a [RIR](./rir.md). + +## Fields + +### Name + +A unique human-friendly name for the range. + +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) + +### RIR + +The [Regional Internet Registry](./rir.md) or similar authority responsible for the allocation of AS numbers within this range. + +### Start & End + +The starting and ending numeric boundaries of the range (inclusive). diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index 29bb5d82f..6cbdefe18 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -20,6 +20,10 @@ This release introduces the ability to render device configurations from Jinja2 The NAPALM integration feature found in previous NetBox releases has been moved from the core application to a dedicated plugin. This allows greater control over the feature's configuration and will unlock additional potential as a separate project. +#### ASN Ranges ([#8550](https://github.com/netbox-community/netbox/issues/8550)) + +A new ASN range model has been introduced to facilitate the provisioning of new autonomous system numbers from within a prescribed range. For example, an administrator might define an ASN range of 65000-65099 to be used for internal site identification. This includes a REST API endpoint suitable for automatic provisioning, very similar to the allocation of available prefixes and IP addresses. + ### Enhancements * [#9073](https://github.com/netbox-community/netbox/issues/9073) - Enable syncing config context data from remote sources diff --git a/mkdocs.yml b/mkdocs.yml index 2487176d3..2c24d2e00 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -215,6 +215,7 @@ nav: - Webhook: 'models/extras/webhook.md' - IPAM: - ASN: 'models/ipam/asn.md' + - ASNRange: 'models/ipam/asnrange.md' - Aggregate: 'models/ipam/aggregate.md' - FHRPGroup: 'models/ipam/fhrpgroup.md' - FHRPGroupAssignment: 'models/ipam/fhrpgroupassignment.md' diff --git a/netbox/circuits/migrations/0001_squashed.py b/netbox/circuits/migrations/0001_squashed.py index 656eb35a1..96fa3c086 100644 --- a/netbox/circuits/migrations/0001_squashed.py +++ b/netbox/circuits/migrations/0001_squashed.py @@ -1,4 +1,4 @@ -import dcim.fields +import ipam.fields from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion @@ -77,7 +77,7 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)), - ('asn', dcim.fields.ASNField(blank=True, null=True)), + ('asn', ipam.fields.ASNField(blank=True, null=True)), ('account', models.CharField(blank=True, max_length=30)), ('portal_url', models.URLField(blank=True)), ('noc_contact', models.TextField(blank=True)), diff --git a/netbox/dcim/fields.py b/netbox/dcim/fields.py index 4a2755be9..cef3283bb 100644 --- a/netbox/dcim/fields.py +++ b/netbox/dcim/fields.py @@ -1,10 +1,8 @@ from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError -from django.core.validators import MinValueValidator, MaxValueValidator from django.db import models from netaddr import AddrFormatError, EUI, eui64_unix_expanded, mac_unix_expanded -from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN from .lookups import PathContains __all__ = ( @@ -27,22 +25,6 @@ class eui64_unix_expanded_uppercase(eui64_unix_expanded): # Fields # -class ASNField(models.BigIntegerField): - description = "32-bit ASN field" - default_validators = [ - MinValueValidator(BGP_ASN_MIN), - MaxValueValidator(BGP_ASN_MAX), - ] - - def formfield(self, **kwargs): - defaults = { - 'min_value': BGP_ASN_MIN, - 'max_value': BGP_ASN_MAX, - } - defaults.update(**kwargs) - return super().formfield(**defaults) - - class MACAddressField(models.Field): description = "PostgreSQL MAC Address field" diff --git a/netbox/dcim/migrations/0001_squashed.py b/netbox/dcim/migrations/0001_squashed.py index 3d7156e17..cf0ef4816 100644 --- a/netbox/dcim/migrations/0001_squashed.py +++ b/netbox/dcim/migrations/0001_squashed.py @@ -1,4 +1,5 @@ import dcim.fields +import ipam.fields import django.contrib.postgres.fields from utilities.json import CustomFieldJSONEncoder import django.core.validators @@ -609,7 +610,7 @@ class Migration(migrations.Migration): ('slug', models.SlugField(max_length=100, unique=True)), ('status', models.CharField(default='active', max_length=50)), ('facility', models.CharField(blank=True, max_length=50)), - ('asn', dcim.fields.ASNField(blank=True, null=True)), + ('asn', ipam.fields.ASNField(blank=True, null=True)), ('time_zone', timezone_field.fields.TimeZoneField(blank=True)), ('description', models.CharField(blank=True, max_length=200)), ('physical_address', models.CharField(blank=True, max_length=200)), diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 7809e84f8..ca8843201 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -7,6 +7,7 @@ from netbox.api.serializers import WritableNestedSerializer __all__ = [ 'NestedAggregateSerializer', 'NestedASNSerializer', + 'NestedASNRangeSerializer', 'NestedFHRPGroupSerializer', 'NestedFHRPGroupAssignmentSerializer', 'NestedIPAddressSerializer', @@ -25,6 +26,18 @@ __all__ = [ ] +# +# ASN ranges +# + +class NestedASNRangeSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asnrange-detail') + + class Meta: + model = models.ASNRange + fields = ['id', 'url', 'display', 'name'] + + # # ASNs # diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 6ec062aee..f62ed0d83 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -15,15 +15,31 @@ from virtualization.api.nested_serializers import NestedVirtualMachineSerializer from .nested_serializers import * +# +# ASN ranges +# + +class ASNRangeSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asnrange-detail') + rir = NestedRIRSerializer() + tenant = NestedTenantSerializer(required=False, allow_null=True) + asn_count = serializers.IntegerField(read_only=True) + + class Meta: + model = ASNRange + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'rir', 'start', 'end', 'tenant', 'description', 'tags', + 'custom_fields', 'created', 'last_updated', 'asn_count', + ] + + # # ASNs # -from .nested_serializers import NestedL2VPNSerializer -from ..models.l2vpn import L2VPNTermination, L2VPN - class ASNSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail') + rir = NestedRIRSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) site_count = serializers.IntegerField(read_only=True) provider_count = serializers.IntegerField(read_only=True) @@ -36,6 +52,22 @@ class ASNSerializer(NetBoxModelSerializer): ] +class AvailableASNSerializer(serializers.Serializer): + """ + Representation of an ASN which does not exist in the database. + """ + asn = serializers.IntegerField(read_only=True) + + def to_representation(self, asn): + rir = NestedRIRSerializer(self.context['range'].rir, context={ + 'request': self.context['request'] + }).data + return { + 'rir': rir, + 'asn': asn, + } + + # # VRFs # diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 1e077c087..442fd2240 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -7,50 +7,33 @@ from . import views router = NetBoxRouter() router.APIRootView = views.IPAMRootView -# ASNs router.register('asns', views.ASNViewSet) - -# VRFs +router.register('asn-ranges', views.ASNRangeViewSet) router.register('vrfs', views.VRFViewSet) - -# Route targets router.register('route-targets', views.RouteTargetViewSet) - -# RIRs router.register('rirs', views.RIRViewSet) - -# Aggregates router.register('aggregates', views.AggregateViewSet) - -# Prefixes router.register('roles', views.RoleViewSet) router.register('prefixes', views.PrefixViewSet) - -# IP ranges router.register('ip-ranges', views.IPRangeViewSet) - -# IP addresses router.register('ip-addresses', views.IPAddressViewSet) - -# FHRP groups router.register('fhrp-groups', views.FHRPGroupViewSet) router.register('fhrp-group-assignments', views.FHRPGroupAssignmentViewSet) - -# VLANs router.register('vlan-groups', views.VLANGroupViewSet) router.register('vlans', views.VLANViewSet) - -# Services router.register('service-templates', views.ServiceTemplateViewSet) router.register('services', views.ServiceViewSet) - -# L2VPN router.register('l2vpns', views.L2VPNViewSet) router.register('l2vpn-terminations', views.L2VPNTerminationViewSet) app_name = 'ipam-api' urlpatterns = [ + path( + 'asn-ranges//available-asns/', + views.AvailableASNsView.as_view(), + name='asnrange-available-asns' + ), path( 'ip-ranges//available-ips/', views.IPRangeAvailableIPAddressesView.as_view(), diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 9ea38758d..4eafd4d3b 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -33,6 +33,12 @@ class IPAMRootView(APIRootView): # Viewsets # +class ASNRangeViewSet(NetBoxModelViewSet): + queryset = ASNRange.objects.prefetch_related('tenant', 'rir').all() + serializer_class = serializers.ASNRangeSerializer + filterset_class = filtersets.ASNRangeFilterSet + + class ASNViewSet(NetBoxModelViewSet): queryset = ASN.objects.prefetch_related('tenant', 'rir').annotate( site_count=count_related(Site, 'asns'), @@ -201,6 +207,74 @@ def get_results_limit(request): return limit +class AvailableASNsView(ObjectValidationMixin, APIView): + queryset = ASN.objects.all() + + @swagger_auto_schema(responses={200: serializers.AvailableASNSerializer(many=True)}) + def get(self, request, pk): + asnrange = get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk) + limit = get_results_limit(request) + + available_asns = asnrange.get_available_asns()[:limit] + + serializer = serializers.AvailableASNSerializer(available_asns, many=True, context={ + 'request': request, + 'range': asnrange, + }) + + return Response(serializer.data) + + @swagger_auto_schema( + request_body=serializers.AvailableASNSerializer, + responses={201: serializers.ASNSerializer(many=True)} + ) + @advisory_lock(ADVISORY_LOCK_KEYS['available-asns']) + def post(self, request, pk): + self.queryset = self.queryset.restrict(request.user, 'add') + asnrange = get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk) + + # Normalize to a list of objects + requested_asns = request.data if isinstance(request.data, list) else [request.data] + + # Determine if the requested number of IPs is available + available_asns = asnrange.get_available_asns() + if len(available_asns) < len(requested_asns): + return Response( + { + "detail": f"An insufficient number of ASNs are available within {asnrange} " + f"({len(requested_asns)} requested, {len(available_asns)} available)" + }, + status=status.HTTP_409_CONFLICT + ) + + # Assign ASNs from the list of available IPs and copy VRF assignment from the parent + for i, requested_asn in enumerate(requested_asns): + requested_asn.update({ + 'rir': asnrange.rir.pk, + 'range': asnrange.pk, + 'asn': available_asns[i], + }) + + # Initialize the serializer with a list or a single object depending on what was requested + context = {'request': request} + if isinstance(request.data, list): + serializer = serializers.ASNSerializer(data=requested_asns, many=True, context=context) + else: + serializer = serializers.ASNSerializer(data=requested_asns[0], context=context) + + # Create the new IP address(es) + if serializer.is_valid(): + try: + with transaction.atomic(): + created = serializer.save() + self._validate_objects(created) + except ObjectDoesNotExist: + raise PermissionDenied() + return Response(serializer.data, status=status.HTTP_201_CREATED) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + class AvailablePrefixesView(ObjectValidationMixin, APIView): queryset = Prefix.objects.all() diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index cb121515d..f26fce2b5 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -2,10 +2,6 @@ from django.db.models import Q from .choices import FHRPGroupProtocolChoices, IPAddressRoleChoices -# BGP ASN bounds -BGP_ASN_MIN = 1 -BGP_ASN_MAX = 2**32 - 1 - # # VRFs diff --git a/netbox/ipam/fields.py b/netbox/ipam/fields.py index 7d28127a4..2d55deae4 100644 --- a/netbox/ipam/fields.py +++ b/netbox/ipam/fields.py @@ -1,10 +1,21 @@ from django.core.exceptions import ValidationError +from django.core.validators import MinValueValidator, MaxValueValidator from django.db import models from netaddr import AddrFormatError, IPNetwork from . import lookups, validators from .formfields import IPNetworkFormField +__all__ = ( + 'ASNField', + 'IPAddressField', + 'IPNetworkField', +) + +# BGP ASN bounds +BGP_ASN_MIN = 1 +BGP_ASN_MAX = 2**32 - 1 + class BaseIPField(models.Field): @@ -93,3 +104,19 @@ IPAddressField.register_lookup(lookups.NetIn) IPAddressField.register_lookup(lookups.NetHostContained) IPAddressField.register_lookup(lookups.NetFamily) IPAddressField.register_lookup(lookups.NetMaskLength) + + +class ASNField(models.BigIntegerField): + description = "32-bit ASN field" + default_validators = [ + MinValueValidator(BGP_ASN_MIN), + MaxValueValidator(BGP_ASN_MAX), + ] + + def formfield(self, **kwargs): + defaults = { + 'min_value': BGP_ASN_MIN, + 'max_value': BGP_ASN_MAX, + } + defaults.update(**kwargs) + return super().formfield(**defaults) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 2e9f56bbc..1a70eaa9e 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -20,6 +20,7 @@ from .models import * __all__ = ( 'AggregateFilterSet', 'ASNFilterSet', + 'ASNRangeFilterSet', 'FHRPGroupAssignmentFilterSet', 'FHRPGroupFilterSet', 'IPAddressFilterSet', @@ -167,6 +168,29 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet): return queryset.none() +class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): + rir_id = django_filters.ModelMultipleChoiceFilter( + queryset=RIR.objects.all(), + label=_('RIR (ID)'), + ) + rir = django_filters.ModelMultipleChoiceFilter( + field_name='rir__slug', + queryset=RIR.objects.all(), + to_field_name='slug', + label=_('RIR (slug)'), + ) + + class Meta: + model = ASNRange + fields = ['id', 'name', 'start', 'end', 'description'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = Q(description__icontains=value) + return queryset.filter(qs_filter) + + class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): rir_id = django_filters.ModelMultipleChoiceFilter( queryset=RIR.objects.all(), diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index e63b34d75..b28a9f959 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -16,6 +16,7 @@ from utilities.forms import ( __all__ = ( 'AggregateBulkEditForm', 'ASNBulkEditForm', + 'ASNRangeBulkEditForm', 'FHRPGroupBulkEditForm', 'IPAddressBulkEditForm', 'IPRangeBulkEditForm', @@ -97,6 +98,28 @@ class RIRBulkEditForm(NetBoxModelBulkEditForm): nullable_fields = ('is_private', 'description') +class ASNRangeBulkEditForm(NetBoxModelBulkEditForm): + rir = DynamicModelChoiceField( + queryset=RIR.objects.all(), + required=False, + label=_('RIR') + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + model = ASNRange + fieldsets = ( + (None, ('rir', 'tenant', 'description')), + ) + nullable_fields = ('description',) + + class ASNBulkEditForm(NetBoxModelBulkEditForm): sites = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), @@ -124,7 +147,7 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm): fieldsets = ( (None, ('sites', 'rir', 'tenant', 'description')), ) - nullable_fields = ('date_added', 'description', 'comments') + nullable_fields = ('tenant', 'description', 'comments') class AggregateBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 972b98db2..9aa592388 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -15,6 +15,7 @@ from virtualization.models import VirtualMachine, VMInterface __all__ = ( 'AggregateImportForm', 'ASNImportForm', + 'ASNRangeImportForm', 'FHRPGroupImportForm', 'IPAddressImportForm', 'IPRangeImportForm', @@ -87,6 +88,24 @@ class AggregateImportForm(NetBoxModelImportForm): fields = ('prefix', 'rir', 'tenant', 'date_added', 'description', 'comments', 'tags') +class ASNRangeImportForm(NetBoxModelImportForm): + rir = CSVModelChoiceField( + queryset=RIR.objects.all(), + to_field_name='name', + help_text=_('Assigned RIR') + ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text=_('Assigned tenant') + ) + + class Meta: + model = ASNRange + fields = ('name', 'slug', 'rir', 'start', 'end', 'tenant', 'description', 'tags') + + class ASNImportForm(NetBoxModelImportForm): rir = CSVModelChoiceField( queryset=RIR.objects.all(), diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 1d505a168..e86ed32f6 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -17,6 +17,7 @@ from virtualization.models import VirtualMachine __all__ = ( 'AggregateFilterForm', 'ASNFilterForm', + 'ASNRangeFilterForm', 'FHRPGroupFilterForm', 'IPAddressFilterForm', 'IPRangeFilterForm', @@ -114,6 +115,27 @@ class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): tag = TagFilterField(model) +class ASNRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): + model = ASNRange + fieldsets = ( + (None, ('q', 'filter_id', 'tag')), + ('Range', ('rir_id', 'start', 'end')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) + rir_id = DynamicModelMultipleChoiceField( + queryset=RIR.objects.all(), + required=False, + label=_('RIR') + ) + start = forms.IntegerField( + required=False + ) + end = forms.IntegerField( + required=False + ) + tag = TagFilterField(model) + + class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = ASN fieldsets = ( diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 4e50c4949..4c14b6bd4 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -20,6 +20,7 @@ from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInter __all__ = ( 'AggregateForm', 'ASNForm', + 'ASNRangeForm', 'FHRPGroupForm', 'FHRPGroupAssignmentForm', 'IPAddressAssignForm', @@ -128,6 +129,24 @@ class AggregateForm(TenancyForm, NetBoxModelForm): } +class ASNRangeForm(TenancyForm, NetBoxModelForm): + rir = DynamicModelChoiceField( + queryset=RIR.objects.all(), + label=_('RIR'), + ) + slug = SlugField() + fieldsets = ( + ('ASN Range', ('name', 'slug', 'rir', 'start', 'end', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + + class Meta: + model = ASNRange + fields = [ + 'name', 'slug', 'rir', 'start', 'end', 'tenant_group', 'tenant', 'description', 'tags' + ] + + class ASNForm(TenancyForm, NetBoxModelForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all(), diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py index 5cd5e030e..3f77de749 100644 --- a/netbox/ipam/graphql/schema.py +++ b/netbox/ipam/graphql/schema.py @@ -8,6 +8,9 @@ class IPAMQuery(graphene.ObjectType): asn = ObjectField(ASNType) asn_list = ObjectListField(ASNType) + asn_range = ObjectField(ASNRangeType) + asn_range_list = ObjectListField(ASNRangeType) + aggregate = ObjectField(AggregateType) aggregate_list = ObjectListField(AggregateType) diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index b8f6221bc..a3405126f 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -1,6 +1,5 @@ import graphene -from graphene_django import DjangoObjectType from extras.graphql.mixins import ContactsMixin from ipam import filtersets, models from netbox.graphql.scalars import BigInt @@ -8,6 +7,7 @@ from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBo __all__ = ( 'ASNType', + 'ASNRangeType', 'AggregateType', 'FHRPGroupType', 'FHRPGroupAssignmentType', @@ -36,6 +36,14 @@ class ASNType(NetBoxObjectType): filterset_class = filtersets.ASNFilterSet +class ASNRangeType(NetBoxObjectType): + + class Meta: + model = models.ASNRange + fields = '__all__' + filterset_class = filtersets.ASNRangeFilterSet + + class AggregateType(NetBoxObjectType): class Meta: diff --git a/netbox/ipam/migrations/0053_asn_model.py b/netbox/ipam/migrations/0053_asn_model.py index 3b074634c..99bde12e6 100644 --- a/netbox/ipam/migrations/0053_asn_model.py +++ b/netbox/ipam/migrations/0053_asn_model.py @@ -1,6 +1,4 @@ -# Generated by Django 3.2.8 on 2021-11-02 16:16 - -import dcim.fields +import ipam.fields from utilities.json import CustomFieldJSONEncoder from django.db import migrations, models import django.db.models.deletion @@ -23,7 +21,7 @@ class Migration(migrations.Migration): ('last_updated', models.DateTimeField(auto_now=True, null=True)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('asn', dcim.fields.ASNField(unique=True)), + ('asn', ipam.fields.ASNField(unique=True)), ('description', models.CharField(blank=True, max_length=200)), ('rir', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='asns', to='ipam.rir')), ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), diff --git a/netbox/ipam/migrations/0064_asnrange.py b/netbox/ipam/migrations/0064_asnrange.py new file mode 100644 index 000000000..6f5cfe6b8 --- /dev/null +++ b/netbox/ipam/migrations/0064_asnrange.py @@ -0,0 +1,41 @@ +# Generated by Django 4.1.7 on 2023-02-26 19:33 + +from django.db import migrations, models +import django.db.models.deletion +import ipam.fields +import taggit.managers +import utilities.json + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0009_standardize_description_comments'), + ('extras', '0087_dashboard'), + ('ipam', '0063_standardize_description_comments'), + ] + + operations = [ + migrations.CreateModel( + name='ASNRange', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(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=utilities.json.CustomFieldJSONEncoder)), + ('description', models.CharField(blank=True, max_length=200)), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(max_length=100, unique=True)), + ('start', ipam.fields.ASNField()), + ('end', ipam.fields.ASNField()), + ('rir', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='asn_ranges', to='ipam.rir')), + ('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='asn_ranges', to='tenancy.tenant')), + ], + options={ + 'verbose_name': 'ASN range', + 'verbose_name_plural': 'ASN ranges', + 'ordering': ('name',), + }, + ), + ] diff --git a/netbox/ipam/models/__init__.py b/netbox/ipam/models/__init__.py index d13ee9076..a00919ee0 100644 --- a/netbox/ipam/models/__init__.py +++ b/netbox/ipam/models/__init__.py @@ -1,4 +1,5 @@ # Ensure that VRFs are imported before IPs/prefixes so dumpdata & loaddata work correctly +from .asns import * from .fhrp import * from .vrfs import * from .ip import * @@ -8,6 +9,7 @@ from .vlans import * __all__ = ( 'ASN', + 'ASNRange', 'Aggregate', 'IPAddress', 'IPRange', diff --git a/netbox/ipam/models/asns.py b/netbox/ipam/models/asns.py new file mode 100644 index 000000000..cb62b69fe --- /dev/null +++ b/netbox/ipam/models/asns.py @@ -0,0 +1,137 @@ +from django.core.exceptions import ValidationError +from django.db import models +from django.urls import reverse +from django.utils.translation import gettext as _ + +from ipam.fields import ASNField +from netbox.models import OrganizationalModel, PrimaryModel + +__all__ = ( + 'ASN', + 'ASNRange', +) + + +class ASNRange(OrganizationalModel): + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + rir = models.ForeignKey( + to='ipam.RIR', + on_delete=models.PROTECT, + related_name='asn_ranges', + verbose_name='RIR' + ) + start = ASNField() + end = ASNField() + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='asn_ranges', + blank=True, + null=True + ) + + class Meta: + ordering = ('name',) + verbose_name = 'ASN range' + verbose_name_plural = 'ASN ranges' + + def __str__(self): + return f'{self.name} ({self.range_as_string()})' + + def get_absolute_url(self): + return reverse('ipam:asnrange', args=[self.pk]) + + @property + def range(self): + return range(self.start, self.end + 1) + + def range_as_string(self): + return f'{self.start}-{self.end}' + + def clean(self): + super().clean() + + if self.end <= self.start: + raise ValidationError(f"Starting ASN ({self.start}) must be lower than ending ASN ({self.end}).") + + def get_child_asns(self): + return ASN.objects.filter( + asn__gte=self.start, + asn__lte=self.end + ) + + def get_available_asns(self): + """ + Return all available ASNs within this range. + """ + range = set(self.range) + existing_asns = set(self.get_child_asns().values_list('asn', flat=True)) + available_asns = sorted(range - existing_asns) + + return available_asns + + +class ASN(PrimaryModel): + """ + An autonomous system (AS) number is typically used to represent an independent routing domain. A site can have + one or more ASNs assigned to it. + """ + rir = models.ForeignKey( + to='ipam.RIR', + on_delete=models.PROTECT, + related_name='asns', + verbose_name='RIR' + ) + asn = ASNField( + unique=True, + verbose_name='ASN', + help_text=_('32-bit autonomous system number') + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='asns', + blank=True, + null=True + ) + + prerequisite_models = ( + 'ipam.RIR', + ) + + class Meta: + ordering = ['asn'] + verbose_name = 'ASN' + verbose_name_plural = 'ASNs' + + def __str__(self): + return f'AS{self.asn_with_asdot}' + + def get_absolute_url(self): + return reverse('ipam:asn', args=[self.pk]) + + @property + def asn_asdot(self): + """ + Return ASDOT notation for AS numbers greater than 16 bits. + """ + if self.asn > 65535: + return f'{self.asn // 65536}.{self.asn % 65536}' + return self.asn + + @property + def asn_with_asdot(self): + """ + Return both plain and ASDOT notation, where applicable. + """ + if self.asn > 65535: + return f'{self.asn} ({self.asn // 65536}.{self.asn % 65536})' + else: + return self.asn diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index e8bf13375..3463b9486 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -8,7 +8,6 @@ from django.urls import reverse from django.utils.functional import cached_property from django.utils.translation import gettext as _ -from dcim.fields import ASNField from ipam.choices import * from ipam.constants import * from ipam.fields import IPNetworkField, IPAddressField @@ -20,7 +19,6 @@ from netbox.models import OrganizationalModel, PrimaryModel __all__ = ( 'Aggregate', - 'ASN', 'IPAddress', 'IPRange', 'Prefix', @@ -74,65 +72,6 @@ class RIR(OrganizationalModel): return reverse('ipam:rir', args=[self.pk]) -class ASN(PrimaryModel): - """ - An autonomous system (AS) number is typically used to represent an independent routing domain. A site can have - one or more ASNs assigned to it. - """ - asn = ASNField( - unique=True, - verbose_name='ASN', - help_text=_('32-bit autonomous system number') - ) - rir = models.ForeignKey( - to='ipam.RIR', - on_delete=models.PROTECT, - related_name='asns', - verbose_name='RIR' - ) - tenant = models.ForeignKey( - to='tenancy.Tenant', - on_delete=models.PROTECT, - related_name='asns', - blank=True, - null=True - ) - - prerequisite_models = ( - 'ipam.RIR', - ) - - class Meta: - ordering = ['asn'] - verbose_name = 'ASN' - verbose_name_plural = 'ASNs' - - def __str__(self): - return f'AS{self.asn_with_asdot}' - - def get_absolute_url(self): - return reverse('ipam:asn', args=[self.pk]) - - @property - def asn_asdot(self): - """ - Return ASDOT notation for AS numbers greater than 16 bits. - """ - if self.asn > 65535: - return f'{self.asn // 65536}.{self.asn % 65536}' - return self.asn - - @property - def asn_with_asdot(self): - """ - Return both plain and ASDOT notation, where applicable. - """ - if self.asn > 65535: - return f'{self.asn} ({self.asn // 65536}.{self.asn % 65536})' - else: - return self.asn - - class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): """ An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize diff --git a/netbox/ipam/search.py b/netbox/ipam/search.py index ad4403321..4d97bf5f0 100644 --- a/netbox/ipam/search.py +++ b/netbox/ipam/search.py @@ -22,6 +22,14 @@ class ASNIndex(SearchIndex): ) +@register_search +class ASNRangeIndex(SearchIndex): + model = models.ASNRange + fields = ( + ('description', 500), + ) + + @register_search class FHRPGroupIndex(SearchIndex): model = models.FHRPGroup diff --git a/netbox/ipam/tables/__init__.py b/netbox/ipam/tables/__init__.py index 3bde78af0..7d04a5fea 100644 --- a/netbox/ipam/tables/__init__.py +++ b/netbox/ipam/tables/__init__.py @@ -1,3 +1,4 @@ +from .asn import * from .fhrp import * from .ip import * from .l2vpn import * diff --git a/netbox/ipam/tables/asn.py b/netbox/ipam/tables/asn.py new file mode 100644 index 000000000..511e914ec --- /dev/null +++ b/netbox/ipam/tables/asn.py @@ -0,0 +1,77 @@ +import django_tables2 as tables +from django.utils.translation import gettext as _ + +from ipam.models import * +from netbox.tables import NetBoxTable, columns +from tenancy.tables import TenancyColumnsMixin + +__all__ = ( + 'ASNTable', + 'ASNRangeTable', +) + + +class ASNRangeTable(TenancyColumnsMixin, NetBoxTable): + name = tables.Column( + linkify=True + ) + rir = tables.Column( + linkify=True + ) + tags = columns.TagColumn( + url_name='ipam:asnrange_list' + ) + asn_count = columns.LinkedCountColumn( + viewname='ipam:asn_list', + url_params={'asn_id': 'pk'}, + verbose_name=_('ASN Count') + ) + + class Meta(NetBoxTable.Meta): + model = ASNRange + fields = ( + 'pk', 'name', 'slug', 'rir', 'start', 'end', 'asn_count', 'tenant', 'tenant_group', 'description', 'tags', + 'created', 'last_updated', 'actions', + ) + default_columns = ('pk', 'name', 'rir', 'start', 'end', 'tenant', 'asn_count', 'description') + + +class ASNTable(TenancyColumnsMixin, NetBoxTable): + asn = tables.Column( + linkify=True + ) + rir = tables.Column( + linkify=True + ) + asn_asdot = tables.Column( + accessor=tables.A('asn_asdot'), + linkify=True, + verbose_name=_('ASDOT') + ) + site_count = columns.LinkedCountColumn( + viewname='dcim:site_list', + url_params={'asn_id': 'pk'}, + verbose_name=_('Site Count') + ) + provider_count = columns.LinkedCountColumn( + viewname='circuits:provider_list', + url_params={'asn_id': 'pk'}, + verbose_name=_('Provider Count') + ) + sites = columns.ManyToManyColumn( + linkify_item=True + ) + comments = columns.MarkdownColumn() + tags = columns.TagColumn( + url_name='ipam:asn_list' + ) + + class Meta(NetBoxTable.Meta): + model = ASN + fields = ( + 'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'provider_count', 'tenant', 'tenant_group', 'description', + 'comments', 'sites', 'tags', 'created', 'last_updated', 'actions', + ) + default_columns = ( + 'pk', 'asn', 'rir', 'site_count', 'provider_count', 'sites', 'description', 'tenant', + ) diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index f83831d2d..37aff148d 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -8,7 +8,6 @@ from tenancy.tables import TenancyColumnsMixin, TenantColumn __all__ = ( 'AggregateTable', - 'ASNTable', 'AssignedIPAddressesTable', 'IPAddressAssignTable', 'IPAddressTable', @@ -93,47 +92,6 @@ class RIRTable(NetBoxTable): default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description') -# -# ASNs -# - -class ASNTable(TenancyColumnsMixin, NetBoxTable): - asn = tables.Column( - linkify=True - ) - asn_asdot = tables.Column( - accessor=tables.A('asn_asdot'), - linkify=True, - verbose_name='ASDOT' - ) - site_count = columns.LinkedCountColumn( - viewname='dcim:site_list', - url_params={'asn_id': 'pk'}, - verbose_name='Site Count' - ) - provider_count = columns.LinkedCountColumn( - viewname='circuits:provider_list', - url_params={'asn_id': 'pk'}, - verbose_name='Provider Count' - ) - sites = columns.ManyToManyColumn( - linkify_item=True, - verbose_name='Sites' - ) - comments = columns.MarkdownColumn() - tags = columns.TagColumn( - url_name='ipam:asn_list' - ) - - class Meta(NetBoxTable.Meta): - model = ASN - fields = ( - 'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'provider_count', 'tenant', 'tenant_group', 'description', - 'comments', 'sites', 'tags', 'created', 'last_updated', 'actions', - ) - default_columns = ('pk', 'asn', 'rir', 'site_count', 'provider_count', 'sites', 'description', 'tenant') - - # # Aggregates # diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index ea6441650..3908c8f3d 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -21,6 +21,118 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) +class ASNRangeTest(APIViewTestCases.APIViewTestCase): + model = ASNRange + brief_fields = ['display', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + rirs = ( + RIR(name='RIR 1', slug='rir-1', is_private=True), + RIR(name='RIR 2', slug='rir-2', is_private=True), + ) + RIR.objects.bulk_create(rirs) + + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + ) + Tenant.objects.bulk_create(tenants) + + asn_ranges = ( + ASNRange(name='ASN Range 1', slug='asn-range-1', rir=rirs[0], tenant=tenants[0], start=100, end=199), + ASNRange(name='ASN Range 2', slug='asn-range-2', rir=rirs[0], tenant=tenants[0], start=200, end=299), + ASNRange(name='ASN Range 3', slug='asn-range-3', rir=rirs[0], tenant=tenants[0], start=300, end=399), + ) + ASNRange.objects.bulk_create(asn_ranges) + + cls.create_data = [ + { + 'name': 'ASN Range 4', + 'slug': 'asn-range-4', + 'rir': rirs[1].pk, + 'start': 400, + 'end': 499, + 'tenant': tenants[1].pk, + }, + { + 'name': 'ASN Range 5', + 'slug': 'asn-range-5', + 'rir': rirs[1].pk, + 'start': 500, + 'end': 599, + 'tenant': tenants[1].pk, + }, + { + 'name': 'ASN Range 6', + 'slug': 'asn-range-6', + 'rir': rirs[1].pk, + 'start': 600, + 'end': 699, + 'tenant': tenants[1].pk, + }, + ] + + def test_list_available_asns(self): + """ + Test retrieval of all available ASNs within a parent range. + """ + rir = RIR.objects.first() + asnrange = ASNRange.objects.create(name='Range 1', slug='range-1', rir=rir, start=101, end=110) + url = reverse('ipam-api:asnrange-available-asns', kwargs={'pk': asnrange.pk}) + self.add_permissions('ipam.view_asnrange', 'ipam.view_asn') + + response = self.client.get(url, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(len(response.data), 10) + + def test_create_single_available_asn(self): + """ + Test creation of the first available ASN within a range. + """ + rir = RIR.objects.first() + asnrange = ASNRange.objects.create(name='Range 1', slug='range-1', rir=rir, start=101, end=110) + url = reverse('ipam-api:asnrange-available-asns', kwargs={'pk': asnrange.pk}) + self.add_permissions('ipam.view_asnrange', 'ipam.add_asn') + + data = { + 'description': 'New ASN' + } + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(response.data['rir']['id'], asnrange.rir.pk) + self.assertEqual(response.data['description'], data['description']) + + def test_create_multiple_available_asns(self): + """ + Test the creation of several available ASNs within a parent range. + """ + rir = RIR.objects.first() + asnrange = ASNRange.objects.create(name='Range 1', slug='range-1', rir=rir, start=101, end=110) + url = reverse('ipam-api:asnrange-available-asns', kwargs={'pk': asnrange.pk}) + self.add_permissions('ipam.view_asnrange', 'ipam.add_asn') + + # Try to create eleven ASNs (only ten are available) + data = [ + {'description': f'New ASN {i}'} + for i in range(1, 12) + ] + assert len(data) == 11 + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_409_CONFLICT) + self.assertIn('detail', response.data) + + # Create all ten available ASNs in a single request + data.pop() + assert len(data) == 10 + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(len(response.data), 10) + + class ASNTest(APIViewTestCases.APIViewTestCase): model = ASN brief_fields = ['asn', 'display', 'id', 'url'] @@ -30,25 +142,29 @@ class ASNTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): + rirs = ( + RIR(name='RIR 1', slug='rir-1', is_private=True), + RIR(name='RIR 2', slug='rir-2', is_private=True), + ) + RIR.objects.bulk_create(rirs) - rirs = [ - RIR.objects.create(name='RFC 6996', slug='rfc-6996', description='Private Use', is_private=True), - RIR.objects.create(name='RFC 7300', slug='rfc-7300', description='IANA Use', is_private=True), - ] - sites = [ - Site.objects.create(name='Site 1', slug='site-1'), - Site.objects.create(name='Site 2', slug='site-2') - ] - tenants = [ - Tenant.objects.create(name='Tenant 1', slug='tenant-1'), - Tenant.objects.create(name='Tenant 2', slug='tenant-2'), - ] + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2') + ) + Site.objects.bulk_create(sites) + + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + ) + Tenant.objects.bulk_create(tenants) asns = ( - ASN(asn=64513, rir=rirs[0], tenant=tenants[0]), - ASN(asn=65534, rir=rirs[0], tenant=tenants[1]), - ASN(asn=4200000000, rir=rirs[0], tenant=tenants[0]), - ASN(asn=4200002301, rir=rirs[1], tenant=tenants[1]), + ASN(asn=65000, rir=rirs[0], tenant=tenants[0]), + ASN(asn=65001, rir=rirs[0], tenant=tenants[1]), + ASN(asn=4200000000, rir=rirs[1], tenant=tenants[0]), + ASN(asn=4200000001, rir=rirs[1], tenant=tenants[1]), ) ASN.objects.bulk_create(asns) @@ -63,12 +179,12 @@ class ASNTest(APIViewTestCases.APIViewTestCase): 'rir': rirs[0].pk, }, { - 'asn': 65543, + 'asn': 65002, 'rir': rirs[0].pk, }, { - 'asn': 4294967294, - 'rir': rirs[0].pk, + 'asn': 4200000002, + 'rir': rirs[1].pk, }, ] diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 13b3ae163..fef4722c4 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -12,84 +12,160 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMac from tenancy.models import Tenant, TenantGroup +class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = ASNRange.objects.all() + filterset = ASNRangeFilterSet + + @classmethod + def setUpTestData(cls): + rirs = [ + RIR(name='RIR 1', slug='rir-1'), + RIR(name='RIR 2', slug='rir-2'), + RIR(name='RIR 3', slug='rir-3'), + ] + RIR.objects.bulk_create(rirs) + + tenants = [ + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + ] + Tenant.objects.bulk_create(tenants) + + asn_ranges = ( + ASNRange( + name='ASN Range 1', + slug='asn-range-1', + rir=rirs[0], + tenant=None, + start=65000, + end=65009, + description='aaa' + ), + ASNRange( + name='ASN Range 2', + slug='asn-range-2', + rir=rirs[1], + tenant=tenants[0], + start=65010, + end=65019, + description='bbb' + ), + ASNRange( + name='ASN Range 3', + slug='asn-range-3', + rir=rirs[2], + tenant=tenants[1], + start=65020, + end=65029, + description='ccc' + ), + ) + ASNRange.objects.bulk_create(asn_ranges) + + def test_name(self): + params = {'name': ['ASN Range 1', 'ASN Range 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_rir(self): + rirs = RIR.objects.all()[:2] + params = {'rir_id': [rirs[0].pk, rirs[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'rir': [rirs[0].slug, rirs[1].slug]} + 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]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'tenant': [tenants[0].slug, tenants[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_start(self): + params = {'start': [65000, 65010]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_end(self): + params = {'end': [65009, 65019]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_description(self): + params = {'description': ['aaa', 'bbb']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + class ASNTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ASN.objects.all() filterset = ASNFilterSet @classmethod def setUpTestData(cls): - rirs = [ - RIR.objects.create(name='RFC 6996', slug='rfc-6996', description='Private Use', is_private=True), - RIR.objects.create(name='RFC 7300', slug='rfc-7300', description='IANA Use', is_private=True), + RIR(name='RIR 1', slug='rir-1', is_private=True), + RIR(name='RIR 2', slug='rir-2', is_private=True), + RIR(name='RIR 3', slug='rir-3', is_private=True), ] + RIR.objects.bulk_create(rirs) + sites = [ - Site.objects.create(name='Site 1', slug='site-1'), - Site.objects.create(name='Site 2', slug='site-2'), - Site.objects.create(name='Site 3', slug='site-3') + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3') ] + Site.objects.bulk_create(sites) + tenants = [ - Tenant.objects.create(name='Tenant 1', slug='tenant-1'), - Tenant.objects.create(name='Tenant 2', slug='tenant-2'), - Tenant.objects.create(name='Tenant 3', slug='tenant-3'), - Tenant.objects.create(name='Tenant 4', slug='tenant-4'), - Tenant.objects.create(name='Tenant 5', slug='tenant-5'), + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + Tenant(name='Tenant 3', slug='tenant-3'), + Tenant(name='Tenant 4', slug='tenant-4'), + Tenant(name='Tenant 5', slug='tenant-5'), ] + Tenant.objects.bulk_create(tenants) asns = ( - ASN(asn=64512, rir=rirs[0], tenant=tenants[0], description='foobar1'), - ASN(asn=64513, rir=rirs[0], tenant=tenants[0], description='foobar2'), - ASN(asn=64514, rir=rirs[0], tenant=tenants[1]), - ASN(asn=64515, rir=rirs[0], tenant=tenants[2]), - ASN(asn=64516, rir=rirs[0], tenant=tenants[3]), - ASN(asn=65535, rir=rirs[1], tenant=tenants[4]), + ASN(asn=65001, rir=rirs[0], tenant=tenants[0], description='aaa'), + ASN(asn=65002, rir=rirs[1], tenant=tenants[1], description='bbb'), + ASN(asn=65003, rir=rirs[2], tenant=tenants[2], description='ccc'), ASN(asn=4200000000, rir=rirs[0], tenant=tenants[0]), - ASN(asn=4200000001, rir=rirs[0], tenant=tenants[1]), - ASN(asn=4200000002, rir=rirs[0], tenant=tenants[2]), - ASN(asn=4200000003, rir=rirs[0], tenant=tenants[3]), - ASN(asn=4200002301, rir=rirs[1], tenant=tenants[4]), + ASN(asn=4200000001, rir=rirs[1], tenant=tenants[1]), + ASN(asn=4200000002, rir=rirs[2], tenant=tenants[2]), ) ASN.objects.bulk_create(asns) asns[0].sites.set([sites[0]]) - asns[1].sites.set([sites[0]]) - asns[2].sites.set([sites[1]]) - asns[3].sites.set([sites[2]]) - asns[4].sites.set([sites[0]]) - asns[5].sites.set([sites[1]]) - asns[6].sites.set([sites[0]]) - asns[7].sites.set([sites[1]]) - asns[8].sites.set([sites[2]]) - asns[9].sites.set([sites[0]]) - asns[10].sites.set([sites[1]]) + asns[1].sites.set([sites[1]]) + asns[2].sites.set([sites[2]]) + asns[3].sites.set([sites[0]]) + asns[4].sites.set([sites[1]]) + asns[5].sites.set([sites[2]]) def test_asn(self): - params = {'asn': ['64512', '65535']} + params = {'asn': [65001, 4200000000]} 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]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'tenant': [tenants[0].slug, tenants[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_rir(self): - rirs = RIR.objects.all()[:1] - params = {'rir_id': [rirs[0].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9) - params = {'rir': [rirs[0].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9) + rirs = RIR.objects.all()[:2] + params = {'rir_id': [rirs[0].pk, rirs[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'rir': [rirs[0].slug, rirs[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'site': [sites[0].slug, sites[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_description(self): - params = {'description': ['foobar1', 'foobar2']} + params = {'description': ['aaa', 'bbb']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 8bf19ebfa..fee308642 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -11,30 +11,91 @@ from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_test_device, create_tags +class ASNRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = ASNRange + + @classmethod + def setUpTestData(cls): + rirs = [ + RIR(name='RIR 1', slug='rir-1', is_private=True), + RIR(name='RIR 2', slug='rir-2', is_private=True), + ] + RIR.objects.bulk_create(rirs) + + tenants = [ + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + ] + Tenant.objects.bulk_create(tenants) + + asn_ranges = ( + ASNRange(name='ASN Range 1', slug='asn-range-1', rir=rirs[0], tenant=tenants[0], start=100, end=199), + ASNRange(name='ASN Range 2', slug='asn-range-2', rir=rirs[0], tenant=tenants[0], start=200, end=299), + ASNRange(name='ASN Range 3', slug='asn-range-3', rir=rirs[0], tenant=tenants[0], start=300, end=399), + ) + ASNRange.objects.bulk_create(asn_ranges) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'ASN Range X', + 'slug': 'asn-range-x', + 'rir': rirs[1].pk, + 'tenant': tenants[1].pk, + 'start': 1000, + 'end': 1099, + 'description': 'A new ASN range', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + f"name,slug,rir,tenant,start,end,description", + f"ASN Range 4,asn-range-4,{rirs[1].name},{tenants[1].name},400,499,Fourth range", + f"ASN Range 5,asn-range-5,{rirs[1].name},{tenants[1].name},500,599,Fifth range", + f"ASN Range 6,asn-range-6,{rirs[1].name},{tenants[1].name},600,699,Sixth range", + ) + + cls.csv_update_data = ( + "id,description", + f"{asn_ranges[0].pk},New description 1", + f"{asn_ranges[1].pk},New description 2", + f"{asn_ranges[2].pk},New description 3", + ) + + cls.bulk_edit_data = { + 'rir': rirs[1].pk, + 'description': 'Next description', + } + + class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = ASN @classmethod def setUpTestData(cls): - rirs = [ - RIR.objects.create(name='RFC 6996', slug='rfc-6996', description='Private Use', is_private=True), - RIR.objects.create(name='RFC 7300', slug='rfc-7300', description='IANA Use', is_private=True), - ] - sites = [ - Site.objects.create(name='Site 1', slug='site-1'), - Site.objects.create(name='Site 2', slug='site-2') - ] - tenants = [ - Tenant.objects.create(name='Tenant 1', slug='tenant-1'), - Tenant.objects.create(name='Tenant 2', slug='tenant-2'), + RIR(name='RIR 1', slug='rir-1', is_private=True), + RIR(name='RIR 2', slug='rir-2', is_private=True), ] + RIR.objects.bulk_create(rirs) + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2') + ) + Site.objects.bulk_create(sites) + + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + ) + Tenant.objects.bulk_create(tenants) asns = ( - ASN(asn=64513, rir=rirs[0], tenant=tenants[0]), - ASN(asn=65535, rir=rirs[1], tenant=tenants[1]), - ASN(asn=4200000000, rir=rirs[0], tenant=tenants[0]), - ASN(asn=4200002301, rir=rirs[1], tenant=tenants[1]), + ASN(asn=65001, rir=rirs[0], tenant=tenants[0]), + ASN(asn=65002, rir=rirs[1], tenant=tenants[1]), + ASN(asn=4200000001, rir=rirs[0], tenant=tenants[0]), + ASN(asn=4200000002, rir=rirs[1], tenant=tenants[1]), ) ASN.objects.bulk_create(asns) @@ -46,18 +107,20 @@ class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase): tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { - 'asn': 64512, + 'asn': 65000, 'rir': rirs[0].pk, 'tenant': tenants[0].pk, 'site': sites[0].pk, 'description': 'A new ASN', + 'tags': [t.pk for t in tags], } cls.csv_data = ( "asn,rir", - "64533,RFC 6996", - "64523,RFC 6996", - "4200000002,RFC 6996", + "65003,RIR 1", + "65004,RIR 2", + "4200000003,RIR 1", + "4200000004,RIR 2", ) cls.csv_update_data = ( diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 032ddf498..3bfe34b7b 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -6,6 +6,14 @@ from . import views app_name = 'ipam' urlpatterns = [ + # ASN ranges + path('asn-ranges/', views.ASNRangeListView.as_view(), name='asnrange_list'), + path('asn-ranges/add/', views.ASNRangeEditView.as_view(), name='asnrange_add'), + path('asn-ranges/import/', views.ASNRangeBulkImportView.as_view(), name='asnrange_import'), + path('asn-ranges/edit/', views.ASNRangeBulkEditView.as_view(), name='asnrange_bulk_edit'), + path('asn-ranges/delete/', views.ASNRangeBulkDeleteView.as_view(), name='asnrange_bulk_delete'), + path('asn-ranges//', include(get_model_urls('ipam', 'asnrange'))), + # ASNs path('asns/', views.ASNListView.as_view(), name='asn_list'), path('asns/add/', views.ASNEditView.as_view(), name='asn_add'), diff --git a/netbox/ipam/utils.py b/netbox/ipam/utils.py index 2903d1e74..93a40e5a0 100644 --- a/netbox/ipam/utils.py +++ b/netbox/ipam/utils.py @@ -1,7 +1,7 @@ import netaddr from .constants import * -from .models import Prefix, VLAN +from .models import ASN, Prefix, VLAN def add_requested_prefixes(parent, prefix_list, show_available=True, show_assigned=True): diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index c80ca7d74..5e9d98324 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -7,16 +7,15 @@ from django.utils.translation import gettext as _ from circuits.models import Provider from dcim.filtersets import InterfaceFilterSet -from dcim.models import Interface, Site, Device +from dcim.models import Interface, Site from netbox.views import generic from utilities.utils import count_related from utilities.views import ViewTab, register_model_view from virtualization.filtersets import VMInterfaceFilterSet -from virtualization.models import VMInterface, VirtualMachine +from virtualization.models import VMInterface from . import filtersets, forms, tables from .constants import * from .models import * -from .models import ASN from .tables.l2vpn import L2VPNTable, L2VPNTerminationTable from .utils import add_requested_prefixes, add_available_ipaddresses, add_available_vlans @@ -195,6 +194,77 @@ class RIRBulkDeleteView(generic.BulkDeleteView): table = tables.RIRTable +# +# ASN ranges +# + +class ASNRangeListView(generic.ObjectListView): + queryset = ASNRange.objects.all() + filterset = filtersets.ASNRangeFilterSet + filterset_form = forms.ASNRangeFilterForm + table = tables.ASNRangeTable + + +@register_model_view(ASNRange) +class ASNRangeView(generic.ObjectView): + queryset = ASNRange.objects.all() + + +@register_model_view(ASNRange, 'asns') +class ASNRangeASNsView(generic.ObjectChildrenView): + queryset = ASNRange.objects.all() + child_model = ASN + table = tables.ASNTable + filterset = filtersets.ASNFilterSet + template_name = 'ipam/asnrange/asns.html' + tab = ViewTab( + label=_('ASNs'), + badge=lambda x: x.get_child_asns().count(), + permission='ipam.view_asns', + weight=500 + ) + + def get_children(self, request, parent): + return parent.get_child_asns().restrict(request.user, 'view').annotate( + site_count=count_related(Site, 'asns'), + provider_count=count_related(Provider, 'asns') + ) + + +@register_model_view(ASNRange, 'edit') +class ASNRangeEditView(generic.ObjectEditView): + queryset = ASNRange.objects.all() + form = forms.ASNRangeForm + + +@register_model_view(ASNRange, 'delete') +class ASNRangeDeleteView(generic.ObjectDeleteView): + queryset = ASNRange.objects.all() + + +class ASNRangeBulkImportView(generic.BulkImportView): + queryset = ASNRange.objects.all() + model_form = forms.ASNRangeImportForm + table = tables.ASNRangeTable + + +class ASNRangeBulkEditView(generic.BulkEditView): + queryset = ASNRange.objects.annotate( + site_count=count_related(Site, 'asns') + ) + filterset = filtersets.ASNRangeFilterSet + table = tables.ASNRangeTable + form = forms.ASNRangeBulkEditForm + + +class ASNRangeBulkDeleteView(generic.BulkDeleteView): + queryset = ASNRange.objects.annotate( + site_count=count_related(Site, 'asns') + ) + filterset = filtersets.ASNRangeFilterSet + table = tables.ASNRangeTable + + # # ASNs # diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 03c361002..35be8cf55 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -158,6 +158,7 @@ IPAM_MENU = Menu( MenuGroup( label=_('ASNs'), items=( + get_model_item('ipam', 'asnrange', _('ASN Ranges')), get_model_item('ipam', 'asn', _('ASNs')), ), ), diff --git a/netbox/templates/ipam/asn.html b/netbox/templates/ipam/asn.html index a54a0aee5..f05febe47 100644 --- a/netbox/templates/ipam/asn.html +++ b/netbox/templates/ipam/asn.html @@ -7,6 +7,9 @@ {% block breadcrumbs %} {{ block.super }} + {% if object.range %} + + {% endif %} {% endblock breadcrumbs %} {% block content %} diff --git a/netbox/templates/ipam/asnrange.html b/netbox/templates/ipam/asnrange.html new file mode 100644 index 000000000..f9ec5765f --- /dev/null +++ b/netbox/templates/ipam/asnrange.html @@ -0,0 +1,57 @@ +{% extends 'ipam/asnrange/base.html' %} +{% load buttons %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} + +{% block content %} +
+
+
+
ASN Range
+
+ + + + + + + + + + + + + + + + + + + + + +
Name{{ object.name }}
RIR + {{ object.rir }} +
Range{{ object.range_as_string }}
Tenant + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} +
Description{{ object.description|placeholder }}
+
+
+ {% plugin_left_page object %} + {% include 'inc/panels/tags.html' %} +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock content %} diff --git a/netbox/templates/ipam/asnrange/asns.html b/netbox/templates/ipam/asnrange/asns.html new file mode 100644 index 000000000..69d4e8abb --- /dev/null +++ b/netbox/templates/ipam/asnrange/asns.html @@ -0,0 +1,36 @@ +{% extends 'ipam/asnrange/base.html' %} +{% load helpers %} + +{% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="ASNTable_config" %} + +
+ {% csrf_token %} + +
+
+ {% include 'htmx/table.html' %} +
+
+ +
+
+ {% if 'bulk_edit' in actions %} + + {% endif %} + {% if 'bulk_delete' in actions %} + + {% endif %} +
+
+
+{% endblock %} + +{% block modals %} + {{ block.super }} + {% table_config_form table %} +{% endblock modals %} diff --git a/netbox/templates/ipam/asnrange/base.html b/netbox/templates/ipam/asnrange/base.html new file mode 100644 index 000000000..2ab472019 --- /dev/null +++ b/netbox/templates/ipam/asnrange/base.html @@ -0,0 +1,6 @@ +{% extends 'generic/object.html' %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock breadcrumbs %} diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index 9303e5f3a..096b60a70 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -43,6 +43,7 @@ ADVISORY_LOCK_KEYS = { 'available-prefixes': 100100, 'available-ips': 100200, 'available-vlans': 100300, + 'available-asns': 100400, } #