Просмотр исходного кода

Oob ip (devices) (#13013)

* initial oob_ip support for devices

* add primary ip and oob ip checkmark to ip address view

* add oob ip to device view and device edit view

* pep8

* make is_oob_ip and is_primary_ip generic for other models

* refactor oob_ip

* fix oob ip signal

* string capitalisation

* Misc cleanup

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
Jamie (Bear) Murphy 2 лет назад
Родитель
Сommit
154b8236a2

+ 4 - 0
docs/models/dcim/device.md

@@ -87,6 +87,10 @@ Each device may designate one primary IPv4 address and/or one primary IPv6 addre
 !!! tip
     NetBox will prefer IPv6 addresses over IPv4 addresses by default. This can be changed by setting the `PREFER_IPV4` configuration parameter.
 
+### Out-of-band (OOB) IP Address
+
+Each device may designate its out-of-band IP address. Out-of-band IPs are typically used to access network infrastructure via a physically separate management network.
+
 ### Cluster
 
 If this device will serve as a host for a virtualization [cluster](../virtualization/cluster.md), it can be assigned here. (Host devices can also be assigned by editing the cluster.)

+ 11 - 10
netbox/dcim/api/serializers.py

@@ -663,6 +663,7 @@ class DeviceSerializer(NetBoxModelSerializer):
     primary_ip = NestedIPAddressSerializer(read_only=True)
     primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
     primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
+    oob_ip = NestedIPAddressSerializer(required=False, allow_null=True)
     parent_device = serializers.SerializerMethodField()
     cluster = NestedClusterSerializer(required=False, allow_null=True)
     virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None)
@@ -686,11 +687,11 @@ class DeviceSerializer(NetBoxModelSerializer):
         fields = [
             'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
             'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status',
-            'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position',
-            'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields',
-            'created', 'last_updated', 'console_port_count', 'console_server_port_count', 'power_port_count',
-            'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count', 'device_bay_count',
-            'module_bay_count', 'inventory_item_count',
+            'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
+            'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags',
+            'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
+            'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
+            'device_bay_count', 'module_bay_count', 'inventory_item_count',
         ]
 
     @extend_schema_field(NestedDeviceSerializer)
@@ -712,11 +713,11 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
         fields = [
             'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
             'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
-            'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
-            'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'config_template',
-            'created', 'last_updated', 'console_port_count', 'console_server_port_count', 'power_port_count',
-            'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count', 'device_bay_count',
-            'module_bay_count', 'inventory_item_count',
+            'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority',
+            'description', 'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context',
+            'config_template', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
+            'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
+            'device_bay_count', 'module_bay_count', 'inventory_item_count',
         ]
 
     @extend_schema_field(serializers.JSONField(allow_null=True))

+ 15 - 0
netbox/dcim/filtersets.py

@@ -941,6 +941,10 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
         method='_has_primary_ip',
         label=_('Has a primary IP'),
     )
+    has_oob_ip = django_filters.BooleanFilter(
+        method='_has_oob_ip',
+        label=_('Has an out-of-band IP'),
+    )
     virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
         field_name='virtual_chassis',
         queryset=VirtualChassis.objects.all(),
@@ -996,6 +1000,11 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
         queryset=IPAddress.objects.all(),
         label=_('Primary IPv6 (ID)'),
     )
+    oob_ip_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='oob_ip',
+        queryset=IPAddress.objects.all(),
+        label=_('OOB IP (ID)'),
+    )
 
     class Meta:
         model = Device
@@ -1020,6 +1029,12 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
             return queryset.filter(params)
         return queryset.exclude(params)
 
+    def _has_oob_ip(self, queryset, name, value):
+        params = Q(oob_ip__isnull=False)
+        if value:
+            return queryset.filter(params)
+        return queryset.exclude(params)
+
     def _virtual_chassis_member(self, queryset, name, value):
         return queryset.exclude(virtual_chassis__isnull=value)
 

+ 8 - 1
netbox/dcim/forms/filtersets.py

@@ -629,7 +629,7 @@ class DeviceFilterForm(
         ('Components', (
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
         )),
-        ('Miscellaneous', ('has_primary_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data'))
+        ('Miscellaneous', ('has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data'))
     )
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
@@ -723,6 +723,13 @@ class DeviceFilterForm(
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
     )
+    has_oob_ip = forms.NullBooleanField(
+        required=False,
+        label='Has an OOB IP',
+        widget=forms.Select(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
     virtual_chassis_member = forms.NullBooleanField(
         required=False,
         label='Virtual chassis member',

+ 7 - 2
netbox/dcim/forms/model_forms.py

@@ -449,9 +449,9 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
         model = Device
         fields = [
             'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face',
-            'latitude', 'longitude', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster',
+            'latitude', 'longitude', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster',
             'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template',
-            'comments', 'tags', 'local_context_data'
+            'comments', 'tags', 'local_context_data',
         ]
 
     def __init__(self, *args, **kwargs):
@@ -460,6 +460,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
         if self.instance.pk:
 
             # Compile list of choices for primary IPv4 and IPv6 addresses
+            oob_ip_choices = [(None, '---------')]
             for family in [4, 6]:
                 ip_choices = [(None, '---------')]
 
@@ -475,6 +476,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
                 if interface_ips:
                     ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips]
                     ip_choices.append(('Interface IPs', ip_list))
+                    oob_ip_choices.extend(ip_list)
                 # Collect NAT IPs
                 nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
                     address__family=family,
@@ -485,6 +487,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
                     ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips]
                     ip_choices.append(('NAT IPs', ip_list))
                 self.fields['primary_ip{}'.format(family)].choices = ip_choices
+            self.fields['oob_ip'].choices = oob_ip_choices
 
             # If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device
             # can be flipped from one face to another.
@@ -504,6 +507,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
             self.fields['primary_ip4'].widget.attrs['readonly'] = True
             self.fields['primary_ip6'].choices = []
             self.fields['primary_ip6'].widget.attrs['readonly'] = True
+            self.fields['oob_ip'].choices = []
+            self.fields['oob_ip'].widget.attrs['readonly'] = True
 
         # Rack position
         position = self.data.get('position') or self.initial.get('position')

+ 25 - 0
netbox/dcim/migrations/0175_device_oob_ip.py

@@ -0,0 +1,25 @@
+# Generated by Django 4.1.9 on 2023-07-24 20:29
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('ipam', '0066_iprange_mark_utilized'),
+        ('dcim', '0174_rack_starting_unit'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='device',
+            name='oob_ip',
+            field=models.OneToOneField(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='+',
+                to='ipam.ipaddress',
+            ),
+        ),
+    ]

+ 1 - 1
netbox/dcim/migrations/0175_device_component_counters.py → netbox/dcim/migrations/0176_device_component_counters.py

@@ -39,7 +39,7 @@ def recalculate_device_counts(apps, schema_editor):
 
 class Migration(migrations.Migration):
     dependencies = [
-        ('dcim', '0174_rack_starting_unit'),
+        ('dcim', '0175_device_oob_ip'),
     ]
 
     operations = [

+ 18 - 1
netbox/dcim/models/devices.py

@@ -591,6 +591,14 @@ class Device(PrimaryModel, ConfigContextModel):
         null=True,
         verbose_name='Primary IPv6'
     )
+    oob_ip = models.OneToOneField(
+        to='ipam.IPAddress',
+        on_delete=models.SET_NULL,
+        related_name='+',
+        blank=True,
+        null=True,
+        verbose_name='Out-of-band IP'
+    )
     cluster = models.ForeignKey(
         to='virtualization.Cluster',
         on_delete=models.SET_NULL,
@@ -816,7 +824,7 @@ class Device(PrimaryModel, ConfigContextModel):
             except DeviceType.DoesNotExist:
                 pass
 
-        # Validate primary IP addresses
+        # Validate primary & OOB IP addresses
         vc_interfaces = self.vc_interfaces(if_master=False)
         if self.primary_ip4:
             if self.primary_ip4.family != 4:
@@ -844,6 +852,15 @@ class Device(PrimaryModel, ConfigContextModel):
                 raise ValidationError({
                     'primary_ip6': f"The specified IP address ({self.primary_ip6}) is not assigned to this device."
                 })
+        if self.oob_ip:
+            if self.oob_ip.assigned_object in vc_interfaces:
+                pass
+            elif self.oob_ip.nat_inside is not None and self.oob_ip.nat_inside.assigned_object in vc_interfaces:
+                pass
+            else:
+                raise ValidationError({
+                    'oob_ip': f"The specified IP address ({self.oob_ip}) is not assigned to this device."
+                })
 
         # Validate manufacturer/platform
         if hasattr(self, 'device_type') and self.platform:

+ 6 - 2
netbox/dcim/tables/devices.py

@@ -201,6 +201,10 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
         linkify=True,
         verbose_name='IPv6 Address'
     )
+    oob_ip = tables.Column(
+        linkify=True,
+        verbose_name='OOB IP'
+    )
     cluster = tables.Column(
         linkify=True
     )
@@ -267,8 +271,8 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
             'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
             'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
             'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4',
-            'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template',
-            'comments', 'contacts', 'tags', 'created', 'last_updated',
+            'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
+            'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated',
         )
         default_columns = (
             'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',

+ 5 - 3
netbox/dcim/views.py

@@ -2452,11 +2452,13 @@ class InterfaceView(generic.ObjectView):
     queryset = Interface.objects.all()
 
     def get_extra_context(self, request, instance):
-        # Get assigned VDC's
+        # Get assigned VDCs
         vdc_table = tables.VirtualDeviceContextTable(
             data=instance.vdcs.restrict(request.user, 'view').prefetch_related('device'),
-            exclude=('tenant', 'tenant_group', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments', 'tags',
-                     'created', 'last_updated', 'actions', ),
+            exclude=(
+                'tenant', 'tenant_group', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'comments', 'tags',
+                'created', 'last_updated', 'actions',
+            ),
             orderable=False
         )
 

+ 18 - 0
netbox/ipam/models/ip.py

@@ -849,6 +849,24 @@ class IPAddress(PrimaryModel):
             return self.address.version
         return None
 
+    @property
+    def is_oob_ip(self):
+        if self.assigned_object:
+            parent = getattr(self.assigned_object, 'parent_object', None)
+            if parent.oob_ip_id == self.pk:
+                return True
+        return False
+
+    @property
+    def is_primary_ip(self):
+        if self.assigned_object:
+            parent = getattr(self.assigned_object, 'parent_object', None)
+            if self.family == 4 and parent.primary_ip4_id == self.pk:
+                return True
+            if self.family == 6 and parent.primary_ip6_id == self.pk:
+                return True
+        return False
+
     def _set_mask_length(self, value):
         """
         Expose the IPNetwork object's prefixlen attribute on the parent model so that it can be manipulated directly,

+ 12 - 6
netbox/ipam/signals.py

@@ -52,13 +52,19 @@ def handle_prefix_deleted(instance, **kwargs):
 @receiver(pre_delete, sender=IPAddress)
 def clear_primary_ip(instance, **kwargs):
     """
-    When an IPAddress is deleted, trigger save() on any Devices/VirtualMachines for which it
-    was a primary IP.
+    When an IPAddress is deleted, trigger save() on any Devices/VirtualMachines for which it was a primary IP.
     """
     field_name = f'primary_ip{instance.family}'
-    device = Device.objects.filter(**{field_name: instance}).first()
-    if device:
+    if device := Device.objects.filter(**{field_name: instance}).first():
         device.save()
-    virtualmachine = VirtualMachine.objects.filter(**{field_name: instance}).first()
-    if virtualmachine:
+    if virtualmachine := VirtualMachine.objects.filter(**{field_name: instance}).first():
         virtualmachine.save()
+
+
+@receiver(pre_delete, sender=IPAddress)
+def clear_oob_ip(instance, **kwargs):
+    """
+    When an IPAddress is deleted, trigger save() on any Devices for which it was a OOB IP.
+    """
+    if device := Device.objects.filter(oob_ip=instance).first():
+        device.save()

+ 11 - 0
netbox/templates/dcim/device.html

@@ -239,6 +239,17 @@
                               {% endif %}
                             </td>
                         </tr>
+                        <tr>
+                            <th scope="row">Out-of-band IP</th>
+                            <td>
+                              {% if object.oob_ip %}
+                                <a href="{{ object.oob_ip.get_absolute_url }}" id="oob_ip">{{ object.oob_ip.address.ip }}</a>
+                                {% copy_content "oob_ip" %}
+                              {% else %}
+                                {{ ''|placeholder }}
+                              {% endif %}
+                            </td>
+                        </tr>
                         {% if object.cluster %}
                             <tr>
                                 <th>Cluster</th>

+ 1 - 0
netbox/templates/dcim/device_edit.html

@@ -68,6 +68,7 @@
       {% if object.pk %}
         {% render_field form.primary_ip4 %}
         {% render_field form.primary_ip6 %}
+        {% render_field form.oob_ip %}
       {% endif %}
     </div>
     

+ 8 - 0
netbox/templates/ipam/ipaddress.html

@@ -96,6 +96,14 @@
                         {% endfor %}
                       </td>
                   </tr>
+                  <tr>
+                    <td>Primary IP</td>
+                    <td>{% checkmark object.is_primary_ip %}</td>
+                  </tr>
+                  <tr>
+                    <td>OOB IP</td>
+                    <td>{% checkmark object.is_oob_ip %}</td>
+                  </tr>
               </table>
           </div>
       </div>