Skip to content

Commit f8f2ad1

Browse files
Closed #9763: Treat IP ranges as fully populated (#19064)
1 parent 076d16c commit f8f2ad1

File tree

16 files changed

+257
-78
lines changed

16 files changed

+257
-78
lines changed

docs/models/ipam/iprange.md

+12
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
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).
44

5+
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.
6+
7+
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.)
8+
9+
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.
10+
511
## Fields
612

713
### VRF
@@ -29,6 +35,12 @@ The IP range's operational status. Note that the status of a range does _not_ ha
2935
!!! tip
3036
Additional statuses may be defined by setting `IPRange.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
3137

38+
### Mark Populated
39+
40+
!!! note "This field was added in NetBox v4.3."
41+
42+
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).
43+
3244
### Mark Utilized
3345

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

netbox/ipam/api/serializers_/ip.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,8 @@ class Meta:
147147
fields = [
148148
'id', 'url', 'display_url', 'display', 'family', 'start_address', 'end_address', 'size', 'vrf', 'tenant',
149149
'status', 'role', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
150-
'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
150+
'mark_populated', 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created',
151+
'last_updated',
151152
]
152153
brief_fields = ('id', 'url', 'display', 'family', 'start_address', 'end_address', 'description')
153154

netbox/ipam/filtersets.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -478,7 +478,7 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
478478

479479
class Meta:
480480
model = IPRange
481-
fields = ('id', 'mark_utilized', 'size', 'description')
481+
fields = ('id', 'mark_populated', 'mark_utilized', 'size', 'description')
482482

483483
def search(self, queryset, name, value):
484484
if not value.strip():

netbox/ipam/forms/bulk_edit.py

+5
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,11 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
296296
queryset=Role.objects.all(),
297297
required=False
298298
)
299+
mark_populated = forms.NullBooleanField(
300+
required=False,
301+
widget=BulkEditNullBooleanSelect(),
302+
label=_('Treat as populated')
303+
)
299304
mark_utilized = forms.NullBooleanField(
300305
required=False,
301306
widget=BulkEditNullBooleanSelect(),

netbox/ipam/forms/bulk_import.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -268,8 +268,8 @@ class IPRangeImportForm(NetBoxModelImportForm):
268268
class Meta:
269269
model = IPRange
270270
fields = (
271-
'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'mark_utilized', 'description',
272-
'comments', 'tags',
271+
'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'mark_populated', 'mark_utilized',
272+
'description', 'comments', 'tags',
273273
)
274274

275275

netbox/ipam/forms/filtersets.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
266266
model = IPRange
267267
fieldsets = (
268268
FieldSet('q', 'filter_id', 'tag'),
269-
FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_utilized', name=_('Attributes')),
269+
FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_populated', 'mark_utilized', name=_('Attributes')),
270270
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
271271
)
272272
family = forms.ChoiceField(
@@ -291,6 +291,13 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
291291
null_option='None',
292292
label=_('Role')
293293
)
294+
mark_populated = forms.NullBooleanField(
295+
required=False,
296+
label=_('Treat as populated'),
297+
widget=forms.Select(
298+
choices=BOOLEAN_WITH_BLANK_CHOICES
299+
)
300+
)
294301
mark_utilized = forms.NullBooleanField(
295302
required=False,
296303
label=_('Treat as fully utilized'),

netbox/ipam/forms/model_forms.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -257,17 +257,17 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
257257

258258
fieldsets = (
259259
FieldSet(
260-
'vrf', 'start_address', 'end_address', 'role', 'status', 'mark_utilized', 'description', 'tags',
261-
name=_('IP Range')
260+
'vrf', 'start_address', 'end_address', 'role', 'status', 'mark_populated', 'mark_utilized', 'description',
261+
'tags', name=_('IP Range')
262262
),
263263
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
264264
)
265265

266266
class Meta:
267267
model = IPRange
268268
fields = [
269-
'vrf', 'start_address', 'end_address', 'status', 'role', 'tenant_group', 'tenant', 'mark_utilized',
270-
'description', 'comments', 'tags',
269+
'vrf', 'start_address', 'end_address', 'status', 'role', 'tenant_group', 'tenant', 'mark_populated',
270+
'mark_utilized', 'description', 'comments', 'tags',
271271
]
272272

273273

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
6+
dependencies = [
7+
('ipam', '0077_vlangroup_tenant'),
8+
]
9+
10+
operations = [
11+
migrations.AddField(
12+
model_name='iprange',
13+
name='mark_populated',
14+
field=models.BooleanField(default=False),
15+
),
16+
]

netbox/ipam/models/ip.py

+40-15
Original file line numberDiff line numberDiff line change
@@ -383,14 +383,15 @@ def get_child_prefixes(self):
383383
else:
384384
return Prefix.objects.filter(prefix__net_contained=str(self.prefix), vrf=self.vrf)
385385

386-
def get_child_ranges(self):
386+
def get_child_ranges(self, **kwargs):
387387
"""
388388
Return all IPRanges within this Prefix and VRF.
389389
"""
390390
return IPRange.objects.filter(
391391
vrf=self.vrf,
392392
start_address__net_host_contained=str(self.prefix),
393-
end_address__net_host_contained=str(self.prefix)
393+
end_address__net_host_contained=str(self.prefix),
394+
**kwargs
394395
)
395396

396397
def get_child_ips(self):
@@ -407,15 +408,14 @@ def get_available_ips(self):
407408
"""
408409
Return all available IPs within this prefix as an IPSet.
409410
"""
410-
if self.mark_utilized:
411-
return netaddr.IPSet()
412-
413411
prefix = netaddr.IPSet(self.prefix)
414-
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
415-
child_ranges = []
416-
for iprange in self.get_child_ranges():
417-
child_ranges.append(iprange.range)
418-
available_ips = prefix - child_ips - netaddr.IPSet(child_ranges)
412+
child_ips = netaddr.IPSet([
413+
ip.address.ip for ip in self.get_child_ips()
414+
])
415+
child_ranges = netaddr.IPSet([
416+
iprange.range for iprange in self.get_child_ranges().filter(mark_populated=True)
417+
])
418+
available_ips = prefix - child_ips - child_ranges
419419

420420
# IPv6 /127's, pool, or IPv4 /31-/32 sets are fully usable
421421
if (self.family == 6 and self.prefix.prefixlen >= 127) or self.is_pool or (
@@ -433,6 +433,7 @@ def get_available_ips(self):
433433
# For IPv6 prefixes, omit the Subnet-Router anycast address
434434
# per RFC 4291
435435
available_ips -= netaddr.IPSet([netaddr.IPAddress(self.prefix.first)])
436+
436437
return available_ips
437438

438439
def get_first_available_ip(self):
@@ -461,9 +462,11 @@ def get_utilization(self):
461462
utilization = float(child_prefixes.size) / self.prefix.size * 100
462463
else:
463464
# Compile an IPSet to avoid counting duplicate IPs
464-
child_ips = netaddr.IPSet(
465-
[_.range for _ in self.get_child_ranges()] + [_.address.ip for _ in self.get_child_ips()]
466-
)
465+
child_ips = netaddr.IPSet()
466+
for iprange in self.get_child_ranges().filter(mark_utilized=True):
467+
child_ips.add(iprange.range)
468+
for ip in self.get_child_ips():
469+
child_ips.add(ip.address.ip)
467470

468471
prefix_size = self.prefix.size
469472
if self.prefix.version == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
@@ -519,14 +522,19 @@ class IPRange(ContactsMixin, PrimaryModel):
519522
null=True,
520523
help_text=_('The primary function of this range')
521524
)
525+
mark_populated = models.BooleanField(
526+
verbose_name=_('mark populated'),
527+
default=False,
528+
help_text=_("Prevent the creation of IP addresses within this range")
529+
)
522530
mark_utilized = models.BooleanField(
523531
verbose_name=_('mark utilized'),
524532
default=False,
525-
help_text=_("Treat as fully utilized")
533+
help_text=_("Report space as 100% utilized")
526534
)
527535

528536
clone_fields = (
529-
'vrf', 'tenant', 'status', 'role', 'description',
537+
'vrf', 'tenant', 'status', 'role', 'description', 'mark_populated', 'mark_utilized',
530538
)
531539

532540
class Meta:
@@ -663,6 +671,9 @@ def get_available_ips(self):
663671
"""
664672
Return all available IPs within this range as an IPSet.
665673
"""
674+
if self.mark_populated:
675+
return netaddr.IPSet()
676+
666677
range = netaddr.IPRange(self.start_address.ip, self.end_address.ip)
667678
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
668679

@@ -875,6 +886,20 @@ def clean(self):
875886
)
876887
})
877888

889+
# Disallow the creation of IPAddresses within an IPRange with mark_populated=True
890+
parent_range = IPRange.objects.filter(
891+
start_address__lte=self.address,
892+
end_address__gte=self.address,
893+
vrf=self.vrf,
894+
mark_populated=True
895+
).first()
896+
if parent_range:
897+
raise ValidationError({
898+
'address': _(
899+
"Cannot create IP address {ip} inside range {range}."
900+
).format(ip=self.address, range=parent_range)
901+
})
902+
878903
if self._original_assigned_object_id and self._original_assigned_object_type_id:
879904
parent = getattr(self.assigned_object, 'parent_object', None)
880905
ct = ObjectType.objects.get_for_id(self._original_assigned_object_type_id)

netbox/ipam/tables/ip.py

+19-3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
__all__ = (
1212
'AggregateTable',
13+
'AnnotatedIPAddressTable',
1314
'AssignedIPAddressesTable',
1415
'IPAddressAssignTable',
1516
'IPAddressTable',
@@ -268,6 +269,10 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable):
268269
verbose_name=_('Role'),
269270
linkify=True
270271
)
272+
mark_populated = columns.BooleanColumn(
273+
verbose_name=_('Marked Populated'),
274+
false_mark=None
275+
)
271276
mark_utilized = columns.BooleanColumn(
272277
verbose_name=_('Marked Utilized'),
273278
false_mark=None
@@ -288,7 +293,8 @@ class Meta(NetBoxTable.Meta):
288293
model = IPRange
289294
fields = (
290295
'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'tenant_group',
291-
'mark_utilized', 'utilization', 'description', 'comments', 'tags', 'created', 'last_updated',
296+
'mark_populated', 'mark_utilized', 'utilization', 'description', 'comments', 'tags', 'created',
297+
'last_updated',
292298
)
293299
default_columns = (
294300
'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
@@ -303,8 +309,8 @@ class Meta(NetBoxTable.Meta):
303309
#
304310

305311
class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
306-
address = tables.TemplateColumn(
307-
template_code=IPADDRESS_LINK,
312+
address = tables.Column(
313+
linkify=True,
308314
verbose_name=_('IP Address')
309315
)
310316
vrf = tables.TemplateColumn(
@@ -369,6 +375,16 @@ class Meta(NetBoxTable.Meta):
369375
}
370376

371377

378+
class AnnotatedIPAddressTable(IPAddressTable):
379+
address = tables.TemplateColumn(
380+
template_code=IPADDRESS_LINK,
381+
verbose_name=_('IP Address')
382+
)
383+
384+
class Meta(IPAddressTable.Meta):
385+
pass
386+
387+
372388
class IPAddressAssignTable(NetBoxTable):
373389
address = tables.TemplateColumn(
374390
template_code=IPADDRESS_ASSIGN_LINK,

netbox/ipam/tables/template_code.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,12 @@
2626
""" + PREFIX_LINK
2727

2828
IPADDRESS_LINK = """
29-
{% if record.pk %}
30-
<a href="{{ record.get_absolute_url }}" id="ipaddress_{{ record.pk }}">{{ record.address }}</a>
29+
{% if record.address or record.start_address %}
30+
<a href="{{ record.get_absolute_url }}">{{ record }}</a>
3131
{% elif perms.ipam.add_ipaddress %}
32-
<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>
32+
<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>
3333
{% else %}
34-
{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available
34+
{{ record.title }}
3535
{% endif %}
3636
"""
3737

netbox/ipam/tests/test_filtersets.py

+18-2
Original file line numberDiff line numberDiff line change
@@ -918,7 +918,9 @@ def setUpTestData(cls):
918918
tenant=None,
919919
role=None,
920920
status=IPRangeStatusChoices.STATUS_ACTIVE,
921-
description='foobar1'
921+
description='foobar1',
922+
mark_populated=True,
923+
mark_utilized=True,
922924
),
923925
IPRange(
924926
start_address='10.0.2.100/24',
@@ -955,7 +957,9 @@ def setUpTestData(cls):
955957
vrf=None,
956958
tenant=None,
957959
role=None,
958-
status=IPRangeStatusChoices.STATUS_ACTIVE
960+
status=IPRangeStatusChoices.STATUS_ACTIVE,
961+
mark_populated=True,
962+
mark_utilized=True,
959963
),
960964
IPRange(
961965
start_address='2001:db8:0:2::1/64',
@@ -1051,6 +1055,18 @@ def test_parent(self):
10511055
params = {'parent': ['10.0.1.0/25']} # Range 10.0.1.100-199 is not fully contained by 10.0.1.0/25
10521056
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
10531057

1058+
def test_mark_utilized(self):
1059+
params = {'mark_utilized': 'true'}
1060+
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
1061+
params = {'mark_utilized': 'false'}
1062+
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
1063+
1064+
def test_mark_populated(self):
1065+
params = {'mark_populated': 'true'}
1066+
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
1067+
params = {'mark_populated': 'false'}
1068+
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
1069+
10541070

10551071
class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
10561072
queryset = IPAddress.objects.all()

0 commit comments

Comments
 (0)