Closed #9763: Treat IP ranges as fully populated (#19064)

This commit is contained in:
Jeremy Stretch 2025-04-09 10:30:11 -04:00 committed by GitHub
parent 076d16ca6b
commit f8f2ad1d14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 256 additions and 77 deletions

View File

@ -2,6 +2,12 @@
This model represents an arbitrary range of individual IPv4 or IPv6 addresses, inclusive of its starting and ending addresses. For instance, the range 192.0.2.10 to 192.0.2.20 has eleven members. (The total member count is available as the `size` property on an IPRange instance.) Like [prefixes](./prefix.md) and [IP addresses](./ipaddress.md), each IP range may optionally be assigned to a [VRF](./vrf.md). This model represents an arbitrary range of individual IPv4 or IPv6 addresses, inclusive of its starting and ending addresses. For instance, the range 192.0.2.10 to 192.0.2.20 has eleven members. (The total member count is available as the `size` property on an IPRange instance.) Like [prefixes](./prefix.md) and [IP addresses](./ipaddress.md), each IP range may optionally be assigned to a [VRF](./vrf.md).
Each IP range can be marked as populated, which instructs NetBox to treat the range as though every IP address within it has been created (even though these individual IP addresses don't actually exist in the database). This can be helpful in scenarios where the management of a subset of IP addresses has been deferred to an external system of record, such as a DHCP server. NetBox will prohibit the creation of individual IP addresses within a range that has been marked as populated.
An IP range can also be marked as utilized. This will cause its utilization to always be reported as 100% when viewing the range or when calculating the utilization of a parent prefix. (If not enabled, a range's utilization is calculated based on the number of IP addresses which have been created within it.)
Typically, IP ranges marked as populated should also be marked as utilized, although there may be scenarios where this is undesirable (e.g. when reclaiming old IP space). An IP range which has been marked as populated but _not_ marked as utilized will always report a utilization of 0%, as it cannot contain child IP addresses.
## Fields ## Fields
### VRF ### VRF
@ -29,6 +35,12 @@ The IP range's operational status. Note that the status of a range does _not_ ha
!!! tip !!! tip
Additional statuses may be defined by setting `IPRange.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. Additional statuses may be defined by setting `IPRange.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
### Mark Populated
!!! note "This field was added in NetBox v4.3."
If enabled, NetBox will treat this IP range as being fully populated when calculating available IP space. It will also prevent the creation of IP addresses which fall within the declared range (and assigned VRF, if any).
### Mark Utilized ### Mark Utilized
If enabled, the IP range will be considered 100% utilized regardless of how many IP addresses are defined within it. This is useful for documenting DHCP ranges, for example. If enabled, the IP range will be considered 100% utilized regardless of how many IP addresses are defined within it. This is useful for documenting DHCP ranges, for example.

View File

@ -147,7 +147,8 @@ class IPRangeSerializer(NetBoxModelSerializer):
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'family', 'start_address', 'end_address', 'size', 'vrf', 'tenant', 'id', 'url', 'display_url', 'display', 'family', 'start_address', 'end_address', 'size', 'vrf', 'tenant',
'status', 'role', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'status', 'role', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'mark_populated', 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created',
'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'family', 'start_address', 'end_address', 'description') brief_fields = ('id', 'url', 'display', 'family', 'start_address', 'end_address', 'description')

View File

@ -478,7 +478,7 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
class Meta: class Meta:
model = IPRange model = IPRange
fields = ('id', 'mark_utilized', 'size', 'description') fields = ('id', 'mark_populated', 'mark_utilized', 'size', 'description')
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -296,6 +296,11 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
queryset=Role.objects.all(), queryset=Role.objects.all(),
required=False required=False
) )
mark_populated = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect(),
label=_('Treat as populated')
)
mark_utilized = forms.NullBooleanField( mark_utilized = forms.NullBooleanField(
required=False, required=False,
widget=BulkEditNullBooleanSelect(), widget=BulkEditNullBooleanSelect(),

View File

@ -268,8 +268,8 @@ class IPRangeImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = IPRange model = IPRange
fields = ( fields = (
'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'mark_utilized', 'description', 'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'mark_populated', 'mark_utilized',
'comments', 'tags', 'description', 'comments', 'tags',
) )

View File

@ -266,7 +266,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = IPRange model = IPRange
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_utilized', name=_('Attributes')), FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_populated', 'mark_utilized', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
) )
family = forms.ChoiceField( family = forms.ChoiceField(
@ -291,6 +291,13 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
null_option='None', null_option='None',
label=_('Role') label=_('Role')
) )
mark_populated = forms.NullBooleanField(
required=False,
label=_('Treat as populated'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
mark_utilized = forms.NullBooleanField( mark_utilized = forms.NullBooleanField(
required=False, required=False,
label=_('Treat as fully utilized'), label=_('Treat as fully utilized'),

View File

@ -257,8 +257,8 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
fieldsets = ( fieldsets = (
FieldSet( FieldSet(
'vrf', 'start_address', 'end_address', 'role', 'status', 'mark_utilized', 'description', 'tags', 'vrf', 'start_address', 'end_address', 'role', 'status', 'mark_populated', 'mark_utilized', 'description',
name=_('IP Range') 'tags', name=_('IP Range')
), ),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
) )
@ -266,8 +266,8 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
class Meta: class Meta:
model = IPRange model = IPRange
fields = [ fields = [
'vrf', 'start_address', 'end_address', 'status', 'role', 'tenant_group', 'tenant', 'mark_utilized', 'vrf', 'start_address', 'end_address', 'status', 'role', 'tenant_group', 'tenant', 'mark_populated',
'description', 'comments', 'tags', 'mark_utilized', 'description', 'comments', 'tags',
] ]

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ipam', '0077_vlangroup_tenant'),
]
operations = [
migrations.AddField(
model_name='iprange',
name='mark_populated',
field=models.BooleanField(default=False),
),
]

View File

@ -383,14 +383,15 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
else: else:
return Prefix.objects.filter(prefix__net_contained=str(self.prefix), vrf=self.vrf) return Prefix.objects.filter(prefix__net_contained=str(self.prefix), vrf=self.vrf)
def get_child_ranges(self): def get_child_ranges(self, **kwargs):
""" """
Return all IPRanges within this Prefix and VRF. Return all IPRanges within this Prefix and VRF.
""" """
return IPRange.objects.filter( return IPRange.objects.filter(
vrf=self.vrf, vrf=self.vrf,
start_address__net_host_contained=str(self.prefix), start_address__net_host_contained=str(self.prefix),
end_address__net_host_contained=str(self.prefix) end_address__net_host_contained=str(self.prefix),
**kwargs
) )
def get_child_ips(self): def get_child_ips(self):
@ -407,15 +408,14 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
""" """
Return all available IPs within this prefix as an IPSet. Return all available IPs within this prefix as an IPSet.
""" """
if self.mark_utilized:
return netaddr.IPSet()
prefix = netaddr.IPSet(self.prefix) prefix = netaddr.IPSet(self.prefix)
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()]) child_ips = netaddr.IPSet([
child_ranges = [] ip.address.ip for ip in self.get_child_ips()
for iprange in self.get_child_ranges(): ])
child_ranges.append(iprange.range) child_ranges = netaddr.IPSet([
available_ips = prefix - child_ips - netaddr.IPSet(child_ranges) iprange.range for iprange in self.get_child_ranges().filter(mark_populated=True)
])
available_ips = prefix - child_ips - child_ranges
# IPv6 /127's, pool, or IPv4 /31-/32 sets are fully usable # IPv6 /127's, pool, or IPv4 /31-/32 sets are fully usable
if (self.family == 6 and self.prefix.prefixlen >= 127) or self.is_pool or ( if (self.family == 6 and self.prefix.prefixlen >= 127) or self.is_pool or (
@ -433,6 +433,7 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
# For IPv6 prefixes, omit the Subnet-Router anycast address # For IPv6 prefixes, omit the Subnet-Router anycast address
# per RFC 4291 # per RFC 4291
available_ips -= netaddr.IPSet([netaddr.IPAddress(self.prefix.first)]) available_ips -= netaddr.IPSet([netaddr.IPAddress(self.prefix.first)])
return available_ips return available_ips
def get_first_available_ip(self): def get_first_available_ip(self):
@ -461,9 +462,11 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
utilization = float(child_prefixes.size) / self.prefix.size * 100 utilization = float(child_prefixes.size) / self.prefix.size * 100
else: else:
# Compile an IPSet to avoid counting duplicate IPs # Compile an IPSet to avoid counting duplicate IPs
child_ips = netaddr.IPSet( child_ips = netaddr.IPSet()
[_.range for _ in self.get_child_ranges()] + [_.address.ip for _ in self.get_child_ips()] for iprange in self.get_child_ranges().filter(mark_utilized=True):
) child_ips.add(iprange.range)
for ip in self.get_child_ips():
child_ips.add(ip.address.ip)
prefix_size = self.prefix.size prefix_size = self.prefix.size
if self.prefix.version == 4 and self.prefix.prefixlen < 31 and not self.is_pool: if self.prefix.version == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
@ -519,14 +522,19 @@ class IPRange(ContactsMixin, PrimaryModel):
null=True, null=True,
help_text=_('The primary function of this range') help_text=_('The primary function of this range')
) )
mark_populated = models.BooleanField(
verbose_name=_('mark populated'),
default=False,
help_text=_("Prevent the creation of IP addresses within this range")
)
mark_utilized = models.BooleanField( mark_utilized = models.BooleanField(
verbose_name=_('mark utilized'), verbose_name=_('mark utilized'),
default=False, default=False,
help_text=_("Treat as fully utilized") help_text=_("Report space as 100% utilized")
) )
clone_fields = ( clone_fields = (
'vrf', 'tenant', 'status', 'role', 'description', 'vrf', 'tenant', 'status', 'role', 'description', 'mark_populated', 'mark_utilized',
) )
class Meta: class Meta:
@ -663,6 +671,9 @@ class IPRange(ContactsMixin, PrimaryModel):
""" """
Return all available IPs within this range as an IPSet. Return all available IPs within this range as an IPSet.
""" """
if self.mark_populated:
return netaddr.IPSet()
range = netaddr.IPRange(self.start_address.ip, self.end_address.ip) range = netaddr.IPRange(self.start_address.ip, self.end_address.ip)
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()]) child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
@ -875,6 +886,20 @@ class IPAddress(ContactsMixin, PrimaryModel):
) )
}) })
# Disallow the creation of IPAddresses within an IPRange with mark_populated=True
parent_range = IPRange.objects.filter(
start_address__lte=self.address,
end_address__gte=self.address,
vrf=self.vrf,
mark_populated=True
).first()
if parent_range:
raise ValidationError({
'address': _(
"Cannot create IP address {ip} inside range {range}."
).format(ip=self.address, range=parent_range)
})
if self._original_assigned_object_id and self._original_assigned_object_type_id: if self._original_assigned_object_id and self._original_assigned_object_type_id:
parent = getattr(self.assigned_object, 'parent_object', None) parent = getattr(self.assigned_object, 'parent_object', None)
ct = ObjectType.objects.get_for_id(self._original_assigned_object_type_id) ct = ObjectType.objects.get_for_id(self._original_assigned_object_type_id)

View File

@ -10,6 +10,7 @@ from .template_code import *
__all__ = ( __all__ = (
'AggregateTable', 'AggregateTable',
'AnnotatedIPAddressTable',
'AssignedIPAddressesTable', 'AssignedIPAddressesTable',
'IPAddressAssignTable', 'IPAddressAssignTable',
'IPAddressTable', 'IPAddressTable',
@ -268,6 +269,10 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable):
verbose_name=_('Role'), verbose_name=_('Role'),
linkify=True linkify=True
) )
mark_populated = columns.BooleanColumn(
verbose_name=_('Marked Populated'),
false_mark=None
)
mark_utilized = columns.BooleanColumn( mark_utilized = columns.BooleanColumn(
verbose_name=_('Marked Utilized'), verbose_name=_('Marked Utilized'),
false_mark=None false_mark=None
@ -288,7 +293,8 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable):
model = IPRange model = IPRange
fields = ( fields = (
'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'tenant_group', 'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'tenant_group',
'mark_utilized', 'utilization', 'description', 'comments', 'tags', 'created', 'last_updated', 'mark_populated', 'mark_utilized', 'utilization', 'description', 'comments', 'tags', 'created',
'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', 'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
@ -303,8 +309,8 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable):
# #
class IPAddressTable(TenancyColumnsMixin, NetBoxTable): class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
address = tables.TemplateColumn( address = tables.Column(
template_code=IPADDRESS_LINK, linkify=True,
verbose_name=_('IP Address') verbose_name=_('IP Address')
) )
vrf = tables.TemplateColumn( vrf = tables.TemplateColumn(
@ -369,6 +375,16 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
} }
class AnnotatedIPAddressTable(IPAddressTable):
address = tables.TemplateColumn(
template_code=IPADDRESS_LINK,
verbose_name=_('IP Address')
)
class Meta(IPAddressTable.Meta):
pass
class IPAddressAssignTable(NetBoxTable): class IPAddressAssignTable(NetBoxTable):
address = tables.TemplateColumn( address = tables.TemplateColumn(
template_code=IPADDRESS_ASSIGN_LINK, template_code=IPADDRESS_ASSIGN_LINK,

View File

@ -26,12 +26,12 @@ PREFIX_LINK_WITH_DEPTH = """
""" + PREFIX_LINK """ + PREFIX_LINK
IPADDRESS_LINK = """ IPADDRESS_LINK = """
{% if record.pk %} {% if record.address or record.start_address %}
<a href="{{ record.get_absolute_url }}" id="ipaddress_{{ record.pk }}">{{ record.address }}</a> <a href="{{ record.get_absolute_url }}">{{ record }}</a>
{% elif perms.ipam.add_ipaddress %} {% elif perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.tenant %}&tenant={{ object.tenant.pk }}{% endif %}&return_url={% url 'ipam:prefix_ipaddresses' pk=object.pk %}" class="btn btn-sm btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a> <a href="{% url 'ipam:ipaddress_add' %}?address={{ record.first_ip }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.tenant %}&tenant={{ object.tenant.pk }}{% endif %}&return_url={% url 'ipam:prefix_ipaddresses' pk=object.pk %}" class="btn btn-sm btn-success">{{ record.title }}</a>
{% else %} {% else %}
{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available {{ record.title }}
{% endif %} {% endif %}
""" """

View File

@ -918,7 +918,9 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
tenant=None, tenant=None,
role=None, role=None,
status=IPRangeStatusChoices.STATUS_ACTIVE, status=IPRangeStatusChoices.STATUS_ACTIVE,
description='foobar1' description='foobar1',
mark_populated=True,
mark_utilized=True,
), ),
IPRange( IPRange(
start_address='10.0.2.100/24', start_address='10.0.2.100/24',
@ -955,7 +957,9 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
vrf=None, vrf=None,
tenant=None, tenant=None,
role=None, role=None,
status=IPRangeStatusChoices.STATUS_ACTIVE status=IPRangeStatusChoices.STATUS_ACTIVE,
mark_populated=True,
mark_utilized=True,
), ),
IPRange( IPRange(
start_address='2001:db8:0:2::1/64', start_address='2001:db8:0:2::1/64',
@ -1051,6 +1055,18 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'parent': ['10.0.1.0/25']} # Range 10.0.1.100-199 is not fully contained by 10.0.1.0/25 params = {'parent': ['10.0.1.0/25']} # Range 10.0.1.100-199 is not fully contained by 10.0.1.0/25
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
def test_mark_utilized(self):
params = {'mark_utilized': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'mark_utilized': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_mark_populated(self):
params = {'mark_populated': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'mark_populated': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = IPAddress.objects.all() queryset = IPAddress.objects.all()

View File

@ -211,16 +211,25 @@ class TestPrefix(TestCase):
IPAddress(address=IPNetwork('10.0.0.5/26')), IPAddress(address=IPNetwork('10.0.0.5/26')),
IPAddress(address=IPNetwork('10.0.0.7/26')), IPAddress(address=IPNetwork('10.0.0.7/26')),
)) ))
# Range is not marked as populated, so it doesn't count against available IP space
IPRange.objects.create( IPRange.objects.create(
start_address=IPNetwork('10.0.0.9/26'), start_address=IPNetwork('10.0.0.9/26'),
end_address=IPNetwork('10.0.0.12/26') end_address=IPNetwork('10.0.0.10/26')
)
# Populated range reduces available IP space
IPRange.objects.create(
start_address=IPNetwork('10.0.0.12/26'),
end_address=IPNetwork('10.0.0.13/26'),
mark_populated=True
) )
missing_ips = IPSet([ missing_ips = IPSet([
'10.0.0.2/32', '10.0.0.2/32',
'10.0.0.4/32', '10.0.0.4/32',
'10.0.0.6/32', '10.0.0.6/32',
'10.0.0.8/32', '10.0.0.8/32',
'10.0.0.13/32', '10.0.0.9/32',
'10.0.0.10/32',
'10.0.0.11/32',
'10.0.0.14/32', '10.0.0.14/32',
]) ])
available_ips = parent_prefix.get_available_ips() available_ips = parent_prefix.get_available_ips()
@ -286,8 +295,12 @@ class TestPrefix(TestCase):
]) ])
self.assertEqual(prefix.get_utilization(), 32 / 254 * 100) # ~12.5% utilization self.assertEqual(prefix.get_utilization(), 32 / 254 * 100) # ~12.5% utilization
# Create a child range with 32 additional IPs # Create a utilized child range with 32 additional IPs
IPRange.objects.create(start_address=IPNetwork('10.0.0.33/24'), end_address=IPNetwork('10.0.0.64/24')) IPRange.objects.create(
start_address=IPNetwork('10.0.0.33/24'),
end_address=IPNetwork('10.0.0.64/24'),
mark_utilized=True
)
self.assertEqual(prefix.get_utilization(), 64 / 254 * 100) # ~25% utilization self.assertEqual(prefix.get_utilization(), 64 / 254 * 100) # ~25% utilization
# #
@ -569,6 +582,27 @@ class TestIPAddress(TestCase):
IPAddress.objects.create(address=IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP) IPAddress.objects.create(address=IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP)
IPAddress.objects.create(address=IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP) IPAddress.objects.create(address=IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP)
#
# Range validation
#
def test_create_ip_in_unpopulated_range(self):
IPRange.objects.create(
start_address=IPNetwork('192.0.2.1/24'),
end_address=IPNetwork('192.0.2.100/24')
)
ip = IPAddress(address=IPNetwork('192.0.2.10/24'))
ip.full_clean()
def test_create_ip_in_populated_range(self):
IPRange.objects.create(
start_address=IPNetwork('192.0.2.1/24'),
end_address=IPNetwork('192.0.2.100/24'),
mark_populated=True
)
ip = IPAddress(address=IPNetwork('192.0.2.10/24'))
self.assertRaises(ValidationError, ip.full_clean)
class TestVLANGroup(TestCase): class TestVLANGroup(TestCase):

View File

@ -1,17 +1,38 @@
from dataclasses import dataclass
import netaddr import netaddr
from django.utils.translation import gettext_lazy as _
from .constants import * from .constants import *
from .models import Prefix, VLAN from .models import Prefix, VLAN
__all__ = ( __all__ = (
'add_available_ipaddresses', 'AvailableIPSpace',
'add_available_vlans', 'add_available_vlans',
'add_requested_prefixes', 'add_requested_prefixes',
'annotate_ip_space',
'get_next_available_prefix', 'get_next_available_prefix',
'rebuild_prefixes', 'rebuild_prefixes',
) )
@dataclass
class AvailableIPSpace:
"""
A representation of available IP space between two IP addresses/ranges.
"""
size: int
first_ip: str
@property
def title(self):
if self.size == 1:
return _('1 IP available')
if self.size <= 65536:
return _('{count} IPs available').format(count=self.size)
return _('Many IPs available')
def add_requested_prefixes(parent, prefix_list, show_available=True, show_assigned=True): def add_requested_prefixes(parent, prefix_list, show_available=True, show_assigned=True):
""" """
Return a list of requested prefixes using show_available, show_assigned filters. If available prefixes are Return a list of requested prefixes using show_available, show_assigned filters. If available prefixes are
@ -42,50 +63,69 @@ def add_requested_prefixes(parent, prefix_list, show_available=True, show_assign
return child_prefixes return child_prefixes
def add_available_ipaddresses(prefix, ipaddress_list, is_pool=False): def annotate_ip_space(prefix):
""" # Compile child objects
Annotate ranges of available IP addresses within a given prefix. If is_pool is True, the first and last IP will be records = []
considered usable (regardless of mask length). records.extend([
""" (iprange.start_address.ip, iprange) for iprange in prefix.get_child_ranges(mark_populated=True)
])
records.extend([
(ip.address.ip, ip) for ip in prefix.get_child_ips()
])
records = sorted(records, key=lambda x: x[0])
# Determine the first & last valid IP addresses in the prefix
if prefix.family == 4 and prefix.mask_length < 31 and not prefix.is_pool:
# Ignore the network and broadcast addresses for non-pool IPv4 prefixes larger than /31
first_ip_in_prefix = netaddr.IPAddress(prefix.prefix.first + 1)
last_ip_in_prefix = netaddr.IPAddress(prefix.prefix.last - 1)
else:
first_ip_in_prefix = netaddr.IPAddress(prefix.prefix.first)
last_ip_in_prefix = netaddr.IPAddress(prefix.prefix.last)
if not records:
return [
AvailableIPSpace(
size=int(last_ip_in_prefix - first_ip_in_prefix + 1),
first_ip=f'{first_ip_in_prefix}/{prefix.mask_length}'
)
]
output = [] output = []
prev_ip = None prev_ip = None
# Ignore the network and broadcast addresses for non-pool IPv4 prefixes larger than /31.
if prefix.version == 4 and prefix.prefixlen < 31 and not is_pool:
first_ip_in_prefix = netaddr.IPAddress(prefix.first + 1)
last_ip_in_prefix = netaddr.IPAddress(prefix.last - 1)
else:
first_ip_in_prefix = netaddr.IPAddress(prefix.first)
last_ip_in_prefix = netaddr.IPAddress(prefix.last)
if not ipaddress_list:
return [(
int(last_ip_in_prefix - first_ip_in_prefix + 1),
'{}/{}'.format(first_ip_in_prefix, prefix.prefixlen)
)]
# Account for any available IPs before the first real IP # Account for any available IPs before the first real IP
if ipaddress_list[0].address.ip > first_ip_in_prefix: if records[0][0] > first_ip_in_prefix:
skipped_count = int(ipaddress_list[0].address.ip - first_ip_in_prefix) output.append(AvailableIPSpace(
first_skipped = '{}/{}'.format(first_ip_in_prefix, prefix.prefixlen) size=int(records[0][0] - first_ip_in_prefix),
output.append((skipped_count, first_skipped)) first_ip=f'{first_ip_in_prefix}/{prefix.mask_length}'
))
# Iterate through existing IPs and annotate free ranges # Add IP ranges & addresses, annotating available space in between records
for ip in ipaddress_list: for record in records:
if prev_ip: if prev_ip:
diff = int(ip.address.ip - prev_ip.address.ip) # Annotate available space
if diff > 1: if (diff := int(record[0]) - int(prev_ip)) > 1:
first_skipped = '{}/{}'.format(prev_ip.address.ip + 1, prefix.prefixlen) first_skipped = f'{prev_ip + 1}/{prefix.mask_length}'
output.append((diff - 1, first_skipped)) output.append(AvailableIPSpace(
output.append(ip) size=diff - 1,
prev_ip = ip first_ip=first_skipped
))
output.append(record[1])
# Update the previous IP address
if hasattr(record[1], 'end_address'):
prev_ip = record[1].end_address.ip
else:
prev_ip = record[0]
# Include any remaining available IPs # Include any remaining available IPs
if prev_ip.address.ip < last_ip_in_prefix: if prev_ip < last_ip_in_prefix:
skipped_count = int(last_ip_in_prefix - prev_ip.address.ip) output.append(AvailableIPSpace(
first_skipped = '{}/{}'.format(prev_ip.address.ip + 1, prefix.prefixlen) size=int(last_ip_in_prefix - prev_ip),
output.append((skipped_count, first_skipped)) first_ip=f'{prev_ip + 1}/{prefix.mask_length}'
))
return output return output

View File

@ -21,7 +21,7 @@ from . import filtersets, forms, tables
from .choices import PrefixStatusChoices from .choices import PrefixStatusChoices
from .constants import * from .constants import *
from .models import * from .models import *
from .utils import add_requested_prefixes, add_available_ipaddresses, add_available_vlans from .utils import add_requested_prefixes, add_available_vlans, annotate_ip_space
# #
@ -619,7 +619,7 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
class PrefixIPAddressesView(generic.ObjectChildrenView): class PrefixIPAddressesView(generic.ObjectChildrenView):
queryset = Prefix.objects.all() queryset = Prefix.objects.all()
child_model = IPAddress child_model = IPAddress
table = tables.IPAddressTable table = tables.AnnotatedIPAddressTable
filterset = filtersets.IPAddressFilterSet filterset = filtersets.IPAddressFilterSet
filterset_form = forms.IPAddressFilterForm filterset_form = forms.IPAddressFilterForm
template_name = 'ipam/prefix/ip_addresses.html' template_name = 'ipam/prefix/ip_addresses.html'
@ -635,7 +635,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
def prep_table_data(self, request, queryset, parent): def prep_table_data(self, request, queryset, parent):
if not request.GET.get('q') and not get_table_ordering(request, self.table): if not request.GET.get('q') and not get_table_ordering(request, self.table):
return add_available_ipaddresses(parent.prefix, queryset, parent.is_pool) return annotate_ip_space(parent)
return queryset return queryset
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):

View File

@ -25,12 +25,19 @@
<th scope="row">{% trans "Size" %}</th> <th scope="row">{% trans "Size" %}</th>
<td>{{ object.size }}</td> <td>{{ object.size }}</td>
</tr> </tr>
<tr>
<th scope="row">{% trans "Marked Populated" %}</th>
<td>{% checkmark object.mark_populated %}</td>
</tr>
<tr>
<th scope="row">{% trans "Marked Utilized" %}</th>
<td>{% checkmark object.mark_utilized %}</td>
</tr>
<tr> <tr>
<th scope="row">{% trans "Utilization" %}</th> <th scope="row">{% trans "Utilization" %}</th>
<td> <td>
{% if object.mark_utilized %} {% if object.mark_utilized %}
{% utilization_graph 100 warning_threshold=0 danger_threshold=0 %} {% utilization_graph 100 warning_threshold=0 danger_threshold=0 %}
<small>({% trans "Marked fully utilized" %})</small>
{% else %} {% else %}
{% utilization_graph object.utilization %} {% utilization_graph object.utilization %}
{% endif %} {% endif %}