From e5cea213c55ef2d7d8a73033388f68cb148e3bd7 Mon Sep 17 00:00:00 2001 From: Jamie Murphy Date: Mon, 26 Jun 2023 22:12:48 +0100 Subject: [PATCH] initial oob_ip support for devices --- netbox/dcim/api/serializers.py | 5 ++ netbox/dcim/filtersets.py | 20 +++++++ netbox/dcim/forms/filtersets.py | 9 +++- netbox/dcim/forms/model_forms.py | 7 ++- .../0173_device_oob_ip4_device_oob_ip6.py | 25 +++++++++ netbox/dcim/models/devices.py | 54 +++++++++++++++++++ netbox/dcim/tables/devices.py | 15 +++++- netbox/dcim/views.py | 2 +- netbox/ipam/signals.py | 11 ++++ 9 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 netbox/dcim/migrations/0173_device_oob_ip4_device_oob_ip6.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 2f854d3e4..2919ea39f 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -663,6 +663,9 @@ 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(read_only=True) + oob_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) + oob_ip6 = 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) @@ -676,6 +679,7 @@ class DeviceSerializer(NetBoxModelSerializer): 'site', 'location', 'rack', 'position', 'face', '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', + 'oob_ip', 'oob_ip4', 'oob_ip6', ] @extend_schema_field(NestedDeviceSerializer) @@ -699,6 +703,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer): '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', 'created', 'last_updated', + 'oob_ip', 'oob_ip4', 'oob_ip6' ] @extend_schema_field(serializers.JSONField(allow_null=True)) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index e784be8e8..8cce3b252 100644 --- a/netbox/dcim/filtersets.py +++ b/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 a OOB IP'), + ) virtual_chassis_id = django_filters.ModelMultipleChoiceFilter( field_name='virtual_chassis', queryset=VirtualChassis.objects.all(), @@ -996,6 +1000,16 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter queryset=IPAddress.objects.all(), label=_('Primary IPv6 (ID)'), ) + oob_ip4_id = django_filters.ModelMultipleChoiceFilter( + field_name='oob_ip4', + queryset=IPAddress.objects.all(), + label=_('OOB IPv4 (ID)'), + ) + oob_ip6_id = django_filters.ModelMultipleChoiceFilter( + field_name='oob_ip6', + queryset=IPAddress.objects.all(), + label=_('OOB IPv6 (ID)'), + ) class Meta: model = Device @@ -1020,6 +1034,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_ip4__isnull=False) | Q(oob_ip6__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) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 4edee6014..e7b63a522 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/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 a OOB IP', + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) virtual_chassis_member = forms.NullBooleanField( required=False, label='Virtual chassis member', diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 219216045..d62eb2723 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -456,7 +456,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm): 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'tags', - 'local_context_data' + 'local_context_data', 'oob_ip4', 'oob_ip6' ] def __init__(self, *args, **kwargs): @@ -490,6 +490,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{}'.format(family)].choices = 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. @@ -509,6 +510,10 @@ 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_ip4'].choices = [] + self.fields['oob_ip4'].widget.attrs['readonly'] = True + self.fields['oob_ip6'].choices = [] + self.fields['oob_ip6'].widget.attrs['readonly'] = True # Rack position position = self.data.get('position') or self.initial.get('position') diff --git a/netbox/dcim/migrations/0173_device_oob_ip4_device_oob_ip6.py b/netbox/dcim/migrations/0173_device_oob_ip4_device_oob_ip6.py new file mode 100644 index 000000000..15d1403ab --- /dev/null +++ b/netbox/dcim/migrations/0173_device_oob_ip4_device_oob_ip6.py @@ -0,0 +1,25 @@ +# Generated by Django 4.1.9 on 2023-06-26 21:06 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0066_iprange_mark_utilized'), + ('dcim', '0172_larger_power_draw_values'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='oob_ip4', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress'), + ), + migrations.AddField( + model_name='device', + name='oob_ip6', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress'), + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 4cf330ffd..4a3eab27f 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -604,6 +604,22 @@ class Device(PrimaryModel, ConfigContextModel): null=True, verbose_name='Primary IPv6' ) + oob_ip4 = models.OneToOneField( + to='ipam.IPAddress', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + verbose_name='OOB IPv4' + ) + oob_ip6 = models.OneToOneField( + to='ipam.IPAddress', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + verbose_name='OOB IPv6' + ) cluster = models.ForeignKey( to='virtualization.Cluster', on_delete=models.SET_NULL, @@ -802,6 +818,33 @@ class Device(PrimaryModel, ConfigContextModel): 'primary_ip6': f"The specified IP address ({self.primary_ip6}) is not assigned to this device." }) + # OOB ip validation + if self.oob_ip4: + if self.oob_ip4.family != 4: + raise ValidationError({ + 'oob_ip4': f"{self.oob_ip4} is not an IPv4 address." + }) + if self.oob_ip4.assigned_object in vc_interfaces: + pass + elif self.oob_ip4.nat_inside is not None and self.oob_ip4.nat_inside.assigned_object in vc_interfaces: + pass + else: + raise ValidationError({ + 'oob_ip4': f"The specified IP address ({self.oob_ip4}) is not assigned to this device." + }) + if self.oob_ip6: + if self.oob_ip6.family != 6: + raise ValidationError({ + 'oob_ip6': f"{self.oob_ip6} is not an IPv6 address." + }) + if self.oob_ip6.assigned_object in vc_interfaces: + pass + elif self.oob_ip6.nat_inside is not None and self.oob_ip6.nat_inside.assigned_object in vc_interfaces: + pass + else: + raise ValidationError({ + 'oob_ip6': f"The specified IP address ({self.oob_ip6}) is not assigned to this device." + }) # Validate manufacturer/platform if hasattr(self, 'device_type') and self.platform: if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer: @@ -912,6 +955,17 @@ class Device(PrimaryModel, ConfigContextModel): else: return None + @property + def oob_ip(self): + if ConfigItem('PREFER_IPV4')() and self.oob_ip4: + return self.oob_ip4 + elif self.oob_ip6: + return self.oob_ip6 + elif self.oob_ip4: + return self.oob_ip4 + else: + return None + @property def interfaces_count(self): return self.vc_interfaces().count() diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index db2655d27..550b7889b 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -201,6 +201,19 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): linkify=True, verbose_name='IPv6 Address' ) + oob_ip = tables.Column( + linkify=True, + order_by=('oob_ip4', 'oob_ip6'), + verbose_name='OOB IP Address' + ) + oob_ip4 = tables.Column( + linkify=True, + verbose_name='OOB IPv4 Address' + ) + oob_ip6 = tables.Column( + linkify=True, + verbose_name='OOB IPv6 Address' + ) cluster = tables.Column( linkify=True ) @@ -238,7 +251,7 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): 'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device', 'device_bay_position', 'position', 'face', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'contacts', - 'tags', 'created', 'last_updated', + 'tags', 'created', 'last_updated', 'oob_ip', 'oob_ip4', 'oob_ip6', ) default_columns = ( 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index b52e0afa5..641b3d529 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2456,7 +2456,7 @@ class InterfaceView(generic.ObjectView): 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', ), + 'created', 'last_updated', 'actions', 'oob_ip', 'oob_ip4', 'oob_ip6' ), orderable=False ) diff --git a/netbox/ipam/signals.py b/netbox/ipam/signals.py index 8555f5e67..4a3763763 100644 --- a/netbox/ipam/signals.py +++ b/netbox/ipam/signals.py @@ -62,3 +62,14 @@ def clear_primary_ip(instance, **kwargs): virtualmachine = VirtualMachine.objects.filter(**{field_name: instance}).first() if virtualmachine: virtualmachine.save() + +@receiver(pre_delete, sender=IPAddress) +def clear_oob_ip(instance, **kwargs): + """ + When an IPAddress is deleted, trigger save() on any Devices/VirtualMachines for which it + was a OOB IP. + """ + field_name = f'oob_ip{instance.family}' + device = Device.objects.filter(**{field_name: instance}).first() + if device: + device.save()