feat(ipam): Allow single-address IP Ranges
Allow IP ranges where start_address equals end_address to model single-IP pools like DHCP or NAT reservations. Add validation tests, filterset coverage, and display logic to render both endpoints. Fixes #21993
This commit is contained in:
parent
df5bc85b48
commit
f66e6f360a
|
|
@ -21,6 +21,13 @@ The [Virtual Routing and Forwarding](./vrf.md) instance in which this IP range e
|
|||
|
||||
The beginning and ending IP addresses (inclusive) which define the boundaries of the range. Both IP addresses must specify the correct mask.
|
||||
|
||||
A range may contain a single IP address by setting the start and end address to the same value, including the same mask length. This is useful when a pool-like construct, such as a DHCP or NAT pool, contains only one usable address.
|
||||
|
||||
!!! note
|
||||
A single-address IP range is not a replacement for an [IP address](./ipaddress.md). Use an IP address object when modeling an address configured on an interface, assigned as a primary IP, or otherwise participating in IP-address-specific relationships. Use an IP range only when the address is being modeled as a pool, reservation range, or similar range-oriented construct.
|
||||
|
||||
The end address is still required. To create a single-address range, enter the same address and mask for both the start and end address.
|
||||
|
||||
!!! note
|
||||
The maximum supported size of an IP range is 2^32 - 1.
|
||||
|
||||
|
|
|
|||
|
|
@ -605,11 +605,12 @@ class IPRange(ContactsMixin, PrimaryModel):
|
|||
'end_address': _("Starting and ending IP address masks must match")
|
||||
})
|
||||
|
||||
# Check that the ending address is greater than the starting address
|
||||
if not self.end_address > self.start_address:
|
||||
# Equal start/end addresses are permitted for single-address ranges.
|
||||
# Use .ip (host portion) rather than the IPNetwork object to avoid comparing prefix lengths again.
|
||||
if self.end_address.ip < self.start_address.ip:
|
||||
raise ValidationError({
|
||||
'end_address': _(
|
||||
"Ending address must be greater than the starting address ({start_address})"
|
||||
"Ending address must be greater than or equal to the starting address ({start_address})"
|
||||
).format(start_address=self.start_address)
|
||||
})
|
||||
|
||||
|
|
@ -677,6 +678,10 @@ class IPRange(ContactsMixin, PrimaryModel):
|
|||
"""
|
||||
Return an efficient string representation of the IP range.
|
||||
"""
|
||||
# Single-address ranges render both endpoints to stay distinct from IPAddress rows.
|
||||
if self.start_address.ip == self.end_address.ip:
|
||||
return f'{self.start_address.ip}-{self.end_address.ip}/{self.start_address.prefixlen}'
|
||||
|
||||
separator = ':' if self.family == 6 else '.'
|
||||
start_chunks = str(self.start_address.ip).split(separator)
|
||||
end_chunks = str(self.end_address.ip).split(separator)
|
||||
|
|
|
|||
|
|
@ -680,6 +680,11 @@ class IPRangeTestCase(APIViewTestCases.APIViewTestCase):
|
|||
'start_address': '192.168.6.10/24',
|
||||
'end_address': '192.168.6.50/24',
|
||||
},
|
||||
{
|
||||
# Single-address range (start == end)
|
||||
'start_address': '192.168.7.10/24',
|
||||
'end_address': '192.168.7.10/24',
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
|
|
|
|||
|
|
@ -1110,6 +1110,25 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||
params = {'mark_populated': 'false'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
|
||||
def test_single_address_range(self):
|
||||
# A range with start_address == end_address must be discoverable by the
|
||||
# start, end, and contains filters.
|
||||
iprange = IPRange(
|
||||
start_address=IPNetwork('10.0.5.1/24'),
|
||||
end_address=IPNetwork('10.0.5.1/24'),
|
||||
)
|
||||
iprange.clean()
|
||||
iprange.save()
|
||||
|
||||
params = {'start_address': ['10.0.5.1']}
|
||||
self.assertIn(iprange, self.filterset(params, self.queryset).qs)
|
||||
|
||||
params = {'end_address': ['10.0.5.1']}
|
||||
self.assertIn(iprange, self.filterset(params, self.queryset).qs)
|
||||
|
||||
params = {'contains': '10.0.5.1/24'}
|
||||
self.assertIn(iprange, self.filterset(params, self.queryset).qs)
|
||||
|
||||
|
||||
class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = IPAddress.objects.all()
|
||||
|
|
|
|||
|
|
@ -101,6 +101,69 @@ class TestIPRange(TestCase):
|
|||
)
|
||||
iprange_4_198_201.clean()
|
||||
|
||||
def test_single_address_range(self):
|
||||
iprange = IPRange(
|
||||
start_address=IPNetwork('192.0.2.10/24'),
|
||||
end_address=IPNetwork('192.0.2.10/24'),
|
||||
)
|
||||
|
||||
iprange.clean()
|
||||
iprange.save()
|
||||
|
||||
self.assertEqual(iprange.size, 1)
|
||||
self.assertEqual(str(iprange), '192.0.2.10-192.0.2.10/24')
|
||||
self.assertEqual(iprange.first_available_ip, '192.0.2.10/24')
|
||||
|
||||
def test_first_available_ip_consumed_single_address_range(self):
|
||||
iprange = IPRange.objects.create(
|
||||
start_address=IPNetwork('192.0.2.10/24'),
|
||||
end_address=IPNetwork('192.0.2.10/24'),
|
||||
)
|
||||
IPAddress.objects.create(address=IPNetwork('192.0.2.10/24'))
|
||||
|
||||
# The sole address in the range is now assigned, so no IPs remain available.
|
||||
self.assertIsNone(iprange.first_available_ip)
|
||||
|
||||
def test_single_address_range_ipv6(self):
|
||||
# IPRange.name has IPv4/IPv6-specific formatting; exercise the IPv6 branch
|
||||
# for a single-address range too.
|
||||
iprange = IPRange(
|
||||
start_address=IPNetwork('2001:db8::10/64'),
|
||||
end_address=IPNetwork('2001:db8::10/64'),
|
||||
)
|
||||
|
||||
iprange.clean()
|
||||
iprange.save()
|
||||
|
||||
self.assertEqual(iprange.size, 1)
|
||||
self.assertEqual(str(iprange), '2001:db8::10-2001:db8::10/64')
|
||||
self.assertEqual(iprange.first_available_ip, '2001:db8::10/64')
|
||||
|
||||
def test_reversed_range(self):
|
||||
iprange = IPRange(
|
||||
start_address=IPNetwork('192.0.2.10/24'),
|
||||
end_address=IPNetwork('192.0.2.9/24'),
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
iprange.clean()
|
||||
|
||||
def test_overlapping_single_address_range(self):
|
||||
IPRange.objects.create(
|
||||
start_address=IPNetwork('192.0.2.10/24'),
|
||||
end_address=IPNetwork('192.0.2.10/24'),
|
||||
)
|
||||
|
||||
iprange = IPRange(
|
||||
start_address=IPNetwork('192.0.2.10/24'),
|
||||
end_address=IPNetwork('192.0.2.10/24'),
|
||||
)
|
||||
|
||||
# Assert the overlap-specific error message so this test cannot pass on a
|
||||
# regression where start_address == end_address is rejected earlier.
|
||||
with self.assertRaisesMessage(ValidationError, 'Defined addresses overlap'):
|
||||
iprange.clean()
|
||||
|
||||
|
||||
class TestPrefix(TestCase):
|
||||
|
||||
|
|
@ -640,6 +703,19 @@ class TestIPAddress(TestCase):
|
|||
ip = IPAddress(address=IPNetwork('192.0.2.10/24'))
|
||||
self.assertRaises(ValidationError, ip.full_clean)
|
||||
|
||||
def test_mark_populated_single_address_range_blocks_ip(self):
|
||||
# A single-address range with mark_populated=True must still block creation
|
||||
# of an IPAddress at the same host with the same mask.
|
||||
IPRange.objects.create(
|
||||
start_address=IPNetwork('192.0.2.10/24'),
|
||||
end_address=IPNetwork('192.0.2.10/24'),
|
||||
mark_populated=True,
|
||||
)
|
||||
ipaddress = IPAddress(address=IPNetwork('192.0.2.10/24'))
|
||||
|
||||
with self.assertRaisesMessage(ValidationError, 'Cannot create IP address'):
|
||||
ipaddress.clean()
|
||||
|
||||
|
||||
class TestVLANGroup(TestCase):
|
||||
|
||||
|
|
|
|||
|
|
@ -617,6 +617,28 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||
url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_prefix_ipaddresses_with_single_address_range(self):
|
||||
# The IP Addresses tab annotates child IP addresses alongside any
|
||||
# mark-populated child IP ranges. Make sure a single-address range
|
||||
# (start_address == end_address) renders without errors and is shown
|
||||
# in its range-like display form rather than as a plain IP address.
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.0/16'))
|
||||
IPAddress.objects.create(address=IPNetwork('192.168.0.1/16'))
|
||||
IPRange.objects.create(
|
||||
start_address=IPNetwork('192.168.0.50/16'),
|
||||
end_address=IPNetwork('192.168.0.50/16'),
|
||||
mark_populated=True,
|
||||
)
|
||||
|
||||
url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
|
||||
response = self.client.get(url)
|
||||
self.assertHttpStatus(response, 200)
|
||||
# The single-address range is rendered with both endpoints, not as
|
||||
# 192.168.0.50/16 (which would make it indistinguishable from an
|
||||
# IPAddress row in this mixed-record view).
|
||||
self.assertContains(response, '192.168.0.50-192.168.0.50/16')
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_prefix_import(self):
|
||||
"""
|
||||
|
|
@ -830,6 +852,8 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||
"VRF 1,10.1.0.1/16,10.1.9.254/16,active",
|
||||
"VRF 1,10.2.0.1/16,10.2.9.254/16,active",
|
||||
"VRF 1,10.3.0.1/16,10.3.9.254/16,active",
|
||||
# Single-address range (start == end)
|
||||
"VRF 1,10.4.0.1/16,10.4.0.1/16,active",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
|
|
@ -865,6 +889,28 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||
url = reverse('ipam:iprange_ipaddresses', kwargs={'pk': iprange.pk})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
def test_create_single_address_range(self):
|
||||
# Exercise the UI form path with start_address == end_address. The
|
||||
# generic test_create_object_with_permission covers the multi-address
|
||||
# case via cls.form_data; this test mirrors that flow for the single-
|
||||
# address case so both paths stay covered.
|
||||
self.add_permissions('ipam.add_iprange')
|
||||
form_data = {
|
||||
'start_address': '192.0.6.10/24',
|
||||
'end_address': '192.0.6.10/24',
|
||||
'status': IPRangeStatusChoices.STATUS_ACTIVE,
|
||||
}
|
||||
initial_count = IPRange.objects.count()
|
||||
|
||||
response = self.client.post(reverse('ipam:iprange_add'), data=form_data)
|
||||
self.assertHttpStatus(response, 302)
|
||||
self.assertEqual(IPRange.objects.count(), initial_count + 1)
|
||||
|
||||
iprange = IPRange.objects.order_by('pk').last()
|
||||
self.assertEqual(str(iprange.start_address), '192.0.6.10/24')
|
||||
self.assertEqual(str(iprange.end_address), '192.0.6.10/24')
|
||||
self.assertEqual(iprange.size, 1)
|
||||
|
||||
|
||||
class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = IPAddress
|
||||
|
|
|
|||
Loading…
Reference in New Issue