Enable specifying mask length when creating IP addresses via available-ips endpoint (#21193)
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled

* Enable specifying mask length when creating IP addresses via available-ips endpoint

Fixes #21144

Allow clients to specify an arbitrary mask length when creating IP addresses
from a parent prefix or range using the 'next available' REST API endpoint.

Changes:
- Updated AvailableIPAddressesView to use PrefixLengthSerializer as write_serializer_class
- Enhanced PrefixLengthSerializer to support both 'prefix' and 'parent' context keys
- Added validation to ensure requested prefix_length >= parent mask_length
- Updated prep_object_data to use requested prefix_length if provided, otherwise fall back to parent mask_length for backwards compatibility
- Updated API schema documentation to reflect PrefixLengthSerializer usage

This enables use cases like creating loopback IP addresses with /32 mask length
from a parent prefix with a shorter mask length.

* Refine available-ips prefix length handling

Keep PrefixLengthSerializer strict for available-prefixes and introduce
AvailableIPRequestSerializer for the available-ips endpoint, where
prefix_length is optional and validated against the parent prefix/range.

* Revert PrefixLengthSerializer to original strict state

PrefixLengthSerializer should remain required and strict for the
available-prefixes endpoint. The optional prefix_length functionality
for available-ips is handled by AvailableIPRequestSerializer.

* Add API test; misc cleanup

---------

Co-authored-by: adionit7 <adionit7@users.noreply.github.com>
Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
This commit is contained in:
Aditya Sharma
2026-01-20 21:50:02 +05:30
committed by GitHub
parent 39f11f28fb
commit 040a2ae9a9
3 changed files with 67 additions and 3 deletions

View File

@@ -19,6 +19,7 @@ from ..field_serializers import IPAddressField, IPNetworkField
__all__ = (
'AggregateSerializer',
'AvailableIPSerializer',
'AvailableIPRequestSerializer',
'AvailablePrefixSerializer',
'IPAddressSerializer',
'IPRangeSerializer',
@@ -147,6 +148,43 @@ class IPRangeSerializer(PrimaryModelSerializer):
# IP addresses
#
class AvailableIPRequestSerializer(serializers.Serializer):
"""
Request payload for creating IP addresses from the available-ips endpoint.
"""
prefix_length = serializers.IntegerField(required=False)
def to_internal_value(self, data):
data = super().to_internal_value(data)
prefix_length = data.get('prefix_length')
if prefix_length is None:
# No override requested; the parent prefix/range mask length will be used.
return data
parent = self.context.get('parent')
if parent is None:
return data
# Validate the requested prefix length
if prefix_length < parent.mask_length:
raise serializers.ValidationError({
'prefix_length': 'Prefix length must be greater than or equal to the parent mask length ({})'.format(
parent.mask_length
)
})
elif parent.family == 4 and prefix_length > 32:
raise serializers.ValidationError({
'prefix_length': 'Invalid prefix length ({}) for IPv6'.format(prefix_length)
})
elif parent.family == 6 and prefix_length > 128:
raise serializers.ValidationError({
'prefix_length': 'Invalid prefix length ({}) for IPv4'.format(prefix_length)
})
return data
class IPAddressSerializer(PrimaryModelSerializer):
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
address = IPAddressField()

View File

@@ -400,7 +400,7 @@ class AvailablePrefixesView(AvailableObjectsView):
class AvailableIPAddressesView(AvailableObjectsView):
queryset = IPAddress.objects.all()
read_serializer_class = serializers.AvailableIPSerializer
write_serializer_class = serializers.AvailableIPSerializer
write_serializer_class = serializers.AvailableIPRequestSerializer
advisory_lock_key = 'available-ips'
def get_available_objects(self, parent, limit=None):
@@ -421,8 +421,9 @@ class AvailableIPAddressesView(AvailableObjectsView):
def prep_object_data(self, requested_objects, available_objects, parent):
available_ips = iter(available_objects)
for i, request_data in enumerate(requested_objects):
prefix_length = request_data.pop('prefix_length', None) or parent.mask_length
request_data.update({
'address': f'{next(available_ips)}/{parent.mask_length}',
'address': f'{next(available_ips)}/{prefix_length}',
'vrf': parent.vrf.pk if parent.vrf else None,
})
@@ -435,7 +436,7 @@ class AvailableIPAddressesView(AvailableObjectsView):
@extend_schema(
methods=["post"],
responses={201: serializers.IPAddressSerializer(many=True)},
request=serializers.IPAddressSerializer(many=True),
request=serializers.AvailableIPRequestSerializer(many=True),
)
def post(self, request, pk):
return super().post(request, pk)

View File

@@ -595,6 +595,31 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(len(response.data), 8)
def test_create_available_ip_with_mask(self):
"""
Test the creation of an available IP address with a specific prefix length.
"""
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'))
url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
self.add_permissions('ipam.view_prefix', 'ipam.add_ipaddress')
# Create an available IP with a specific prefix length
data = {
'prefix_length': 32,
'description': 'Test IP 1',
}
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(response.data['address'], '192.0.2.1/32')
self.assertEqual(response.data['description'], data['description'])
# Attempt to create an available IP with a prefix length less than its parent prefix
data = {
'prefix_length': 23, # Prefix is a /24
}
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
@tag('regression')
def test_graphql_tenant_prefixes_contains_nested_skips_invalid(self):
"""