initial oob_ip support for devices

This commit is contained in:
Jamie Murphy 2023-06-26 22:12:48 +01:00
parent 290ffd408a
commit e5cea213c5
9 changed files with 144 additions and 4 deletions

View File

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

View File

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

View File

@ -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',

View File

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

View File

@ -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'),
),
]

View File

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

View File

@ -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',

View File

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

View File

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