diff --git a/netbox_cmdb/netbox_cmdb/migrations/0039_logicalinterface.py b/netbox_cmdb/netbox_cmdb/migrations/0039_logicalinterface.py new file mode 100644 index 0000000..63d0b15 --- /dev/null +++ b/netbox_cmdb/netbox_cmdb/migrations/0039_logicalinterface.py @@ -0,0 +1,39 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0060_alter_l2vpn_slug'), + ('netbox_cmdb', '0038_alter_vrf_unique_together_remove_vrf_device'), + ] + + operations = [ + migrations.CreateModel( + name='LogicalInterface', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('index', models.PositiveSmallIntegerField()), + ('enabled', models.BooleanField(default=True)), + ('state', models.CharField(default='staging', max_length=50)), + ('monitoring_state', models.CharField(default='disabled', max_length=50)), + ('mtu', models.PositiveIntegerField(blank=True, null=True)), + ('type', models.CharField(default=None, max_length=2)), + ('mode', models.CharField(blank=True, default=None, max_length=20, null=True)), + ('description', models.CharField(blank=True, max_length=100, null=True)), + ('ipv4_address', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_ipv4_address', to='ipam.ipaddress')), + ('ipv6_address', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_ipv6_address', to='ipam.ipaddress')), + ('native_vlan', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_native_vlan', to='netbox_cmdb.vlan')), + ('parent_interface', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s', to='netbox_cmdb.deviceinterface')), + ('tagged_vlans', models.ManyToManyField(blank=True, default=None, related_name='%(class)s_tagged_vlans', to='netbox_cmdb.vlan')), + ('untagged_vlan', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_untagged_vlan', to='netbox_cmdb.vlan')), + ('vrf', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_vrf', to='netbox_cmdb.vrf')), + ], + options={ + 'unique_together': {('index', 'parent_interface')}, + }, + ), + ] diff --git a/netbox_cmdb/netbox_cmdb/models/interface.py b/netbox_cmdb/netbox_cmdb/models/interface.py index ae06b45..0b7a4ae 100644 --- a/netbox_cmdb/netbox_cmdb/models/interface.py +++ b/netbox_cmdb/netbox_cmdb/models/interface.py @@ -1,6 +1,7 @@ from django.db import models from netbox_cmdb.choices import AssetStateChoices, AssetMonitoringStateChoices from netbox.models import ChangeLoggedModel +from django.core.exceptions import ValidationError FEC_CHOICES = [ (None, "None"), @@ -8,6 +9,17 @@ ("fc", "FireCode"), ] +LOGICAL_INTERFACE_TYPE_CHOICES = [ + ("l1", "L1"), + ("l2", "L2"), + ("l3", "L3"), +] +LOGICAL_INTERFACE_MODE_CHOICES = [ + (None, "None"), + ("access", "Access"), + ("tagged", "Tagged"), +] + class DeviceInterface(ChangeLoggedModel): """A device interface configuration.""" @@ -52,3 +64,95 @@ def __str__(self): class Meta: unique_together = ("device", "name") + + +class LogicalInterface(ChangeLoggedModel): + """A logical interface configuration.""" + + index = models.PositiveSmallIntegerField() + enabled = models.BooleanField(default=True) + state = models.CharField( + max_length=50, + choices=AssetStateChoices, + default=AssetStateChoices.STATE_STAGING, + help_text="State of this DeviceInterface", + ) + monitoring_state = models.CharField( + max_length=50, + choices=AssetMonitoringStateChoices, + default=AssetMonitoringStateChoices.DISABLED, + help_text="Monitoring state of this DeviceInterface", + ) + parent_interface = models.ForeignKey( + to="DeviceInterface", related_name="%(class)s", on_delete=models.CASCADE + ) + mtu = models.PositiveIntegerField(blank=True, null=True) + type = models.CharField( + choices=LOGICAL_INTERFACE_TYPE_CHOICES, + max_length=2, + default=None, + ) + vrf = models.ForeignKey( + to="VRF", related_name="%(class)s_vrf", on_delete=models.CASCADE, blank=True, null=True + ) + ipv4_address = models.ForeignKey( + to="ipam.IPAddress", + related_name="%(class)s_ipv4_address", + on_delete=models.CASCADE, + blank=True, + null=True, + ) + ipv6_address = models.ForeignKey( + to="ipam.IPAddress", + related_name="%(class)s_ipv6_address", + on_delete=models.CASCADE, + blank=True, + null=True, + ) + mode = models.CharField( + choices=LOGICAL_INTERFACE_MODE_CHOICES, + blank=True, + null=True, + default=None, + max_length=20, + help_text="Interface mode (802.1Q)", + ) + untagged_vlan = models.ForeignKey( + to="VLAN", + related_name="%(class)s_untagged_vlan", + on_delete=models.CASCADE, + blank=True, + null=True, + default=None, + ) + tagged_vlans = models.ManyToManyField( + to="VLAN", related_name="%(class)s_tagged_vlans", blank=True, default=None + ) + native_vlan = models.ForeignKey( + to="VLAN", + related_name="%(class)s_native_vlan", + on_delete=models.CASCADE, + blank=True, + null=True, + default=None, + ) + description = models.CharField(max_length=100, blank=True, null=True) + + def __str__(self): + return f"{self.parent_interface.name}--{self.index}" + + def clean(self): + # List of checks to perform + if self.untagged_vlan and (self.tagged_vlans.exists() or self.native_vlan): + raise ValidationError( + "Untagged VLAN cannot be combined with tagged VLANs or native VLAN." + ) + + super(LogicalInterface, self).clean() + + def save(self, *args, **kwargs): + self.full_clean() + super(LogicalInterface, self).save(*args, **kwargs) + + class Meta: + unique_together = ("index", "parent_interface") diff --git a/netbox_cmdb/netbox_cmdb/tests/interface/__init__.py b/netbox_cmdb/netbox_cmdb/tests/interface/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/netbox_cmdb/netbox_cmdb/tests/interface/test_logical_interface_models.py b/netbox_cmdb/netbox_cmdb/tests/interface/test_logical_interface_models.py new file mode 100644 index 0000000..18dcb89 --- /dev/null +++ b/netbox_cmdb/netbox_cmdb/tests/interface/test_logical_interface_models.py @@ -0,0 +1,114 @@ +from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site +from django.forms import ValidationError +from django.test import TestCase + +from netbox_cmdb.models import VLAN, DeviceInterface, LogicalInterface + + +class BaseTestCase(TestCase): + def setUp(self): + site = Site.objects.create(name="SiteTest", slug="site-test") + manufacturer = Manufacturer.objects.create(name="test", slug="test") + device_type = DeviceType.objects.create( + manufacturer=manufacturer, model="model-test", slug="model-test" + ) + device_role = DeviceRole.objects.create(name="role-test", slug="role-test") + device = Device.objects.create( + name="router-test", + device_role=device_role, + device_type=device_type, + site=site, + ) + + DeviceInterface.objects.create( + name="etp1", + enabled=True, + state="staging", + monitoring_state="warning", + device=device, + autonegotiation=True, + speed=100000, + fec="rs", + description="My device interface", + ) + VLAN.objects.create(vid=1, name="VLAN 1", description="First VLAN") + + def test_valid_logical_interface(self): + """Test that a logical interface can be created.""" + vlan = VLAN.objects.get(vid=1) + device_interface = DeviceInterface.objects.get(name="etp1") + + logical_interface = LogicalInterface.objects.create( + index=1, + enabled=True, + state="staging", + monitoring_state="disabled", + parent_interface=device_interface, + mtu=1500, + type="l3", + description="My logical interface", + ) + + logical_interface.tagged_vlans.add(vlan) + logical_interface.save() + + def test_invalid_logical_interface_untagged_and_tagged_vlans(self): + """Test that a logical interface cannot have both tagged and untagged VLANs.""" + device_interface = DeviceInterface.objects.get(name="etp1") + + # Create VLAN instances + vlan1 = VLAN.objects.create(vid=100, name="VLAN 100", description="First VLAN") + vlan2 = VLAN.objects.create(vid=200, name="VLAN 200", description="Second VLAN") + + # Create a working LogicalInterface + logical_interface = LogicalInterface.objects.create( + index=2, + enabled=True, + state="staging", + monitoring_state="disabled", + parent_interface=device_interface, + mtu=1500, + type="l3", + description="My logical interface", + ) + + # Set an untagged VLAN + logical_interface.untagged_vlan = vlan1 + logical_interface.save() + + with self.assertRaisesRegex( + ValidationError, "Untagged VLAN cannot be combined with tagged VLANs or native VLAN." + ): + # Add a tagged VLAN + logical_interface.tagged_vlans.add(vlan2) + logical_interface.save() + + def test_invalid_logical_interface_untagged_and_native_vlans(self): + """Test that a logical interface cannot have both untagged and native VLANs.""" + device_interface = DeviceInterface.objects.get(name="etp1") + + vlan1 = VLAN.objects.create(vid=1000, name="VLAN 1000", description="First VLAN") + vlan2 = VLAN.objects.create(vid=2000, name="VLAN 2000", description="Second VLAN") + + # Create a working LogicalInterface + logical_interface = LogicalInterface.objects.create( + index=3, + enabled=True, + state="staging", + monitoring_state="disabled", + parent_interface=device_interface, + mtu=1500, + type="l3", + description="My logical interface", + ) + + # Set an untagged VLAN + logical_interface.untagged_vlan = vlan1 + logical_interface.save() + + with self.assertRaisesRegex( + ValidationError, "Untagged VLAN cannot be combined with tagged VLANs or native VLAN." + ): + # Set a native VLAN + logical_interface.native_vlan = vlan2 + logical_interface.save()