Quellcode durchsuchen

Enable specifying mask length when creating IP addresses via available-ips endpoint (#21193)

* 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>
Aditya Sharma vor 3 Wochen
Ursprung
Commit
040a2ae9a9
3 geänderte Dateien mit 67 neuen und 3 gelöschten Zeilen
  1. 38 0
      netbox/ipam/api/serializers_/ip.py
  2. 4 3
      netbox/ipam/api/views.py
  3. 25 0
      netbox/ipam/tests/test_api.py

+ 38 - 0
netbox/ipam/api/serializers_/ip.py

@@ -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()

+ 4 - 3
netbox/ipam/api/views.py

@@ -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)

+ 25 - 0
netbox/ipam/tests/test_api.py

@@ -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):
         """