Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add LogicalInterface model #23

Merged
merged 1 commit into from
Jan 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions netbox_cmdb/netbox_cmdb/migrations/0039_logicalinterface.py
Original file line number Diff line number Diff line change
@@ -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')},
},
),
]
104 changes: 104 additions & 0 deletions netbox_cmdb/netbox_cmdb/models/interface.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
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"),
("rs", "Reed Solomon"),
("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."""
Expand Down Expand Up @@ -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 LogicalInterface",
)
monitoring_state = models.CharField(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessarily for this review, but this is more to start discussions about this:

I don't recall if it has been discussed yet, but how do we deal with the monitoring_state if the parent interface says "disabled", and the logical interface says "critical" for instance ? (and vice versa)

I guess we should implement some sort of precedence between these 2 entities. At first sight it would make sense that a parent interface put in "disabled" mode, would override/inhibit the value on the children interface (logical ones).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC we briefly talked about it, but nothing more. Let's schedule a dedicated meeting. The solution could also impact BGPSession monitoring fields

Copy link
Contributor

@kpetremann kpetremann Jan 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it has already been discussed indeed, there was a page and a consensus :)
I'll share the page in slack

EDIT: we are actually missing details about this point in the page.

max_length=50,
choices=AssetMonitoringStateChoices,
default=AssetMonitoringStateChoices.DISABLED,
help_text="Monitoring state of this LogicalInterface",
)
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(
djothi marked this conversation as resolved.
Show resolved Hide resolved
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")
Empty file.
Original file line number Diff line number Diff line change
@@ -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()