Skip to content

Commit ba63a00

Browse files
authored
Merge pull request #10 from nscaledev/sr/add_cluster_lookup_devices
feat: add cluster name filter for devices and fix cluster scope labels
2 parents dd111c3 + ff234b3 commit ba63a00

File tree

7 files changed

+241
-71
lines changed

7 files changed

+241
-71
lines changed

netbox_prometheus_sd/api/utils.py

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -66,21 +66,23 @@ def extract_cluster(obj, labels: LabelDict):
6666
labels["cluster_group"] = obj.cluster.group.name
6767
if obj.cluster.type:
6868
labels["cluster_type"] = obj.cluster.type.name
69-
try: # Netbox >4.2
70-
if obj.cluster.scope:
71-
labels["scope"] = obj.cluster.scope.name
72-
labels["scope_slug"] = obj.cluster.scope.slug
73-
except AttributeError: # Netbox <4.2
74-
if obj.cluster.site:
75-
labels["site"] = obj.cluster.site.name
76-
labels["site_slug"] = obj.cluster.site.slug
77-
78-
# Has precedence over cluster scope
79-
if hasattr(obj, "scope") and obj.scope is not None:
80-
labels["scope"] = obj.scope.name
81-
labels["scope_slug"] = obj.scope.slug
82-
83-
# Still Return site labels for Devices
69+
# NetBox 4.2+ uses scope (generic FK) instead of site
70+
if hasattr(obj.cluster, "scope") and obj.cluster.scope is not None:
71+
scope = obj.cluster.scope
72+
# scope can be region, site_group, site, or location
73+
if hasattr(scope, "slug"):
74+
labels["cluster_scope"] = scope.name
75+
labels["cluster_scope_slug"] = scope.slug
76+
# If scope is a site, also set site labels
77+
if scope.__class__.__name__ == "Site":
78+
labels["site"] = scope.name
79+
labels["site_slug"] = scope.slug
80+
# NetBox < 4.2 uses site directly
81+
elif hasattr(obj.cluster, "site") and obj.cluster.site:
82+
labels["site"] = obj.cluster.site.name
83+
labels["site_slug"] = obj.cluster.site.slug
84+
85+
# Has precedence over cluster site/scope
8486
if hasattr(obj, "site") and obj.site is not None:
8587
labels["site"] = obj.site.name
8688
labels["site_slug"] = obj.site.slug

netbox_prometheus_sd/api/views.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,13 @@ class NetboxPrometheusSDModelViewSet(
3030
# https://github.com/netbox-community/netbox/commit/1024782b9e0abb48f6da65f8248741227d53dbed#diff-d9224204dab475bbe888868c02235b8ef10f07c9201c45c90804d395dc161c40
3131
try:
3232
from ipam.filtersets import IPAddressFilterSet
33-
from dcim.filtersets import DeviceFilterSet
3433
from virtualization.filtersets import VirtualMachineFilterSet
3534
except ImportError:
3635
from ipam.filters import IPAddressFilterSet
37-
from dcim.filters import DeviceFilterSet
3836
from virtualization.filters import VirtualMachineFilterSet
3937

4038

41-
from ..filtersets import ServiceFilterSet
39+
from ..filtersets import ServiceFilterSet, DeviceFilterSet
4240
from .serializers import (
4341
PrometheusIPAddressSerializer,
4442
PrometheusDeviceSerializer,
@@ -94,6 +92,8 @@ class DeviceViewSet(NetboxPrometheusSDModelViewSet):
9492
"primary_ip4__nat_outside",
9593
"primary_ip6__nat_outside",
9694
"tags",
95+
"cluster__group",
96+
"cluster__type",
9797
)
9898
filterset_class = DeviceFilterSet
9999
serializer_class = PrometheusDeviceSerializer

netbox_prometheus_sd/filtersets.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@
1414
except ImportError:
1515
from ipam.filters import ServiceFilterSet as NetboxServiceFilterSet
1616

17+
try:
18+
from dcim.filtersets import DeviceFilterSet as NetboxDeviceFilterSet
19+
except ImportError:
20+
from dcim.filters import DeviceFilterSet as NetboxDeviceFilterSet
21+
1722

1823
class ServiceFilterSet(NetboxServiceFilterSet):
1924
"""Filter set to support tenancy over the device/VM foreign key.
@@ -59,3 +64,23 @@ def filter_by_tenant_slug(self, queryset, name, value):
5964
Q(device__tenant__slug__in=value)
6065
| Q(virtual_machine__tenant__slug__in=value)
6166
)
67+
68+
69+
class DeviceFilterSet(NetboxDeviceFilterSet):
70+
"""Extends NetBox's DeviceFilterSet to add cluster name filter.
71+
72+
NetBox's DeviceFilterSet only has cluster_id, not cluster (name).
73+
This adds the missing cluster name filter for consistency with other filters.
74+
Cluster model doesn't have a slug field, so we filter by name (case-insensitive).
75+
"""
76+
77+
cluster = MultiValueCharFilter(
78+
method='filter_by_cluster_name',
79+
label=_('Cluster (name)'),
80+
)
81+
82+
def filter_by_cluster_name(self, queryset, name, value):
83+
q = Q()
84+
for v in value:
85+
q |= Q(cluster__name__iexact=v)
86+
return queryset.filter(q)

netbox_prometheus_sd/tests/test_filtersets.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from django.test import TestCase
22

3+
from dcim.models import Device
34
from ipam.models import Service
45
from tenancy.models import Tenant
56
from utilities.testing import ChangeLoggedFilterSetTests
67

78
from . import utils
8-
from ..filtersets import ServiceFilterSet
9+
from ..filtersets import ServiceFilterSet, DeviceFilterSet
910

1011

1112
class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
@@ -34,3 +35,47 @@ def test_vm_tenant(self):
3435
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
3536
params = {"tenant": [tenant.slug]}
3637
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
38+
39+
40+
class DeviceFilterSetTestCase(TestCase):
41+
"""Test cases for DeviceFilterSet cluster name filter.
42+
43+
Note: We don't inherit ChangeLoggedFilterSetTests because NetBox's own
44+
DeviceFilterSet is missing some filters (vc_master_for_id) which would
45+
cause that test to fail.
46+
"""
47+
queryset = Device.objects.all()
48+
filterset = DeviceFilterSet
49+
50+
@classmethod
51+
def setUpTestData(cls):
52+
"""Create test devices with and without clusters."""
53+
# Devices with clusters
54+
utils.build_device_with_cluster("device-cluster-01", cluster_name="SYS2-STA1")
55+
utils.build_device_with_cluster("device-cluster-02", cluster_name="SYS2-STA1")
56+
utils.build_device_with_cluster("device-cluster-03", cluster_name="SYS3-STA2")
57+
# Device without cluster
58+
utils.build_minimal_device("device-no-cluster-01")
59+
60+
def test_filter_by_cluster_name_exact(self):
61+
"""Test filtering devices by exact cluster name."""
62+
params = {'cluster': ['SYS2-STA1']}
63+
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
64+
65+
def test_filter_by_cluster_name_case_insensitive(self):
66+
"""Test filtering devices by cluster name is case-insensitive."""
67+
params = {'cluster': ['sys2-sta1']}
68+
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
69+
70+
params = {'cluster': ['Sys2-Sta1']}
71+
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
72+
73+
def test_filter_by_cluster_name_multiple(self):
74+
"""Test filtering devices by multiple cluster names."""
75+
params = {'cluster': ['SYS2-STA1', 'SYS3-STA2']}
76+
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
77+
78+
def test_filter_by_cluster_name_no_match(self):
79+
"""Test filtering devices by non-existent cluster name returns empty."""
80+
params = {'cluster': ['NONEXISTENT']}
81+
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)

netbox_prometheus_sd/tests/test_serializers.py

Lines changed: 106 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,28 @@
11
from django.test import TestCase
2+
from unittest.mock import MagicMock
3+
24
from ..api.serializers import (
35
PrometheusDeviceSerializer,
46
PrometheusIPAddressSerializer,
57
PrometheusServiceSerializer,
68
PrometheusVirtualMachineSerializer,
79
)
10+
from ..api.utils import LabelDict, extract_cluster
811
from . import utils
912

1013
from ..api.utils import NETBOX_RELEASE_CURRENT, NETBOX_RELEASE_41
1114

12-
class PrometheusVirtualMachineSerializerTests(TestCase):
15+
class DictSubsetMixin:
16+
"""Mixin to provide assertDictContainsSubset which was removed in Python 3.12."""
17+
18+
def assertDictContainsSubset(self, subset, dictionary, msg=None):
19+
"""Check that all key/value pairs in subset are in dictionary."""
20+
for key, value in subset.items():
21+
self.assertIn(key, dictionary, msg=msg)
22+
self.assertEqual(dictionary[key], value, msg=msg)
23+
24+
25+
class PrometheusVirtualMachineSerializerTests(DictSubsetMixin, TestCase):
1326
def test_vm_minimal_to_target(self):
1427

1528
instance = utils.build_minimal_vm("vm-01.example.com")
@@ -102,23 +115,12 @@ def test_vm_full_to_target(self):
102115
if NETBOX_RELEASE_CURRENT > NETBOX_RELEASE_41:
103116
self.assertTrue(
104117
utils.dictContainsSubset(
105-
{"__meta_netbox_scope": "Campus A"}, data["labels"]
106-
)
107-
)
108-
self.assertTrue(
109-
utils.dictContainsSubset(
110-
{"__meta_netbox_scope_slug": "campus-a"}, data["labels"]
111-
)
112-
)
113-
else:
114-
self.assertTrue(
115-
utils.dictContainsSubset(
116-
{"__meta_netbox_site": "Campus A"}, data["labels"]
118+
{"__meta_netbox_cluster_scope": "Campus A"}, data["labels"]
117119
)
118120
)
119121
self.assertTrue(
120122
utils.dictContainsSubset(
121-
{"__meta_netbox_site_slug": "campus-a"}, data["labels"]
123+
{"__meta_netbox_cluster_scope_slug": "campus-a"}, data["labels"]
122124
)
123125
)
124126
self.assertTrue(
@@ -200,7 +202,7 @@ def test_vm_full_to_target(self):
200202
)
201203

202204

203-
class PrometheusDeviceSerializerTests(TestCase):
205+
class PrometheusDeviceSerializerTests(DictSubsetMixin, TestCase):
204206
def test_device_minimal_to_target(self):
205207
instance = utils.build_minimal_device("firewall-01")
206208
data = PrometheusDeviceSerializer(many=True, instance=[instance]).data[0]
@@ -364,7 +366,7 @@ def test_device_full_to_target(self):
364366
)
365367

366368

367-
class PrometheusIPAddressSerializerTests(TestCase):
369+
class PrometheusIPAddressSerializerTests(DictSubsetMixin, TestCase):
368370
def test_ip_minimal_to_target(self):
369371
instance = utils.build_minimal_ip("10.10.10.10/24")
370372
data = PrometheusIPAddressSerializer(many=True, instance=[instance]).data[0]
@@ -440,7 +442,7 @@ def test_ip_full_to_target(self):
440442
)
441443

442444

443-
class PrometheusServiceSerializerTests(TestCase):
445+
class PrometheusServiceSerializerTests(DictSubsetMixin, TestCase):
444446
def test_device_service_full_to_target(self):
445447
device = utils.build_device_full("firewall-full-01")
446448
instance = device.services.first()
@@ -548,23 +550,12 @@ def test_vm_service_full_to_target(self):
548550
if NETBOX_RELEASE_CURRENT > NETBOX_RELEASE_41:
549551
self.assertTrue(
550552
utils.dictContainsSubset(
551-
{"__meta_netbox_scope": "Campus A"}, data["labels"]
553+
{"__meta_netbox_cluster_scope": "Campus A"}, data["labels"]
552554
)
553555
)
554556
self.assertTrue(
555557
utils.dictContainsSubset(
556-
{"__meta_netbox_scope_slug": "campus-a"}, data["labels"]
557-
)
558-
)
559-
else:
560-
self.assertTrue(
561-
utils.dictContainsSubset(
562-
{"__meta_netbox_site": "Campus A"}, data["labels"]
563-
)
564-
)
565-
self.assertTrue(
566-
utils.dictContainsSubset(
567-
{"__meta_netbox_site_slug": "campus-a"}, data["labels"]
558+
{"__meta_netbox_cluster_scope_slug": "campus-a"}, data["labels"]
568559
)
569560
)
570561
self.assertTrue(
@@ -582,3 +573,88 @@ def test_vm_service_full_to_target(self):
582573
{"__meta_netbox_primary_ip6": "2001:db8:1701::2"}, data["labels"]
583574
)
584575
)
576+
577+
578+
class ExtractClusterTests(TestCase):
579+
"""Tests for extract_cluster function handling both NetBox 4.2+ scope and older site."""
580+
581+
def test_extract_cluster_basic_info(self):
582+
"""Test that basic cluster info (name, group, type) is extracted."""
583+
device = utils.build_device_with_cluster("device-cluster-test", cluster_name="TestCluster")
584+
labels = LabelDict()
585+
extract_cluster(device, labels)
586+
587+
self.assertEqual(labels.get("cluster"), "TestCluster")
588+
self.assertEqual(labels.get("cluster_group"), "VMware")
589+
self.assertEqual(labels.get("cluster_type"), "On Prem")
590+
591+
def test_extract_cluster_scope_from_scope(self):
592+
"""Test that scope labels are extracted from cluster scope (NetBox 4.2+)."""
593+
device = utils.build_device_with_cluster("device-scope-test", cluster_name="ScopeCluster")
594+
labels = LabelDict()
595+
extract_cluster(device, labels)
596+
597+
# In NetBox 4.2+, cluster scope is labeled as 'cluster_scope'
598+
self.assertEqual(labels.get("cluster_scope"), "Cluster Site")
599+
self.assertEqual(labels.get("cluster_scope_slug"), "cluster-site")
600+
# Device's own site takes precedence for 'site' label
601+
self.assertEqual(labels.get("site"), "Site")
602+
self.assertEqual(labels.get("site_slug"), "site")
603+
604+
def test_extract_cluster_no_cluster(self):
605+
"""Test that no cluster labels are added when device has no cluster."""
606+
device = utils.build_minimal_device("device-no-cluster-test")
607+
labels = LabelDict()
608+
extract_cluster(device, labels)
609+
610+
self.assertIsNone(labels.get("cluster"))
611+
self.assertIsNone(labels.get("cluster_group"))
612+
self.assertIsNone(labels.get("cluster_type"))
613+
614+
def test_extract_cluster_device_site_overrides_cluster_site(self):
615+
"""Test that device's own site takes precedence over cluster site."""
616+
device = utils.build_device_with_cluster("device-site-override", cluster_name="OverrideCluster")
617+
# Device's own site should override cluster site
618+
labels = LabelDict()
619+
extract_cluster(device, labels)
620+
621+
# Device has its own site set in build_minimal_device ("Site", "site")
622+
# which should take precedence over the cluster's site
623+
self.assertEqual(labels.get("site"), "Site")
624+
self.assertEqual(labels.get("site_slug"), "site")
625+
626+
def test_extract_cluster_with_scope_mock(self):
627+
"""Test extract_cluster with mocked scope object (NetBox 4.2+ style)."""
628+
# Create a mock object to simulate NetBox 4.2+ cluster with scope
629+
mock_site = MagicMock()
630+
mock_site.name = "Scoped Site"
631+
mock_site.slug = "scoped-site"
632+
mock_site.__class__.__name__ = "Site"
633+
634+
mock_cluster = MagicMock()
635+
mock_cluster.name = "MockCluster"
636+
mock_cluster.group = MagicMock(name="MockGroup")
637+
mock_cluster.group.name = "Mock Group"
638+
mock_cluster.type = MagicMock(name="MockType")
639+
mock_cluster.type.name = "Mock Type"
640+
mock_cluster.scope = mock_site
641+
# Ensure hasattr returns True for scope
642+
del mock_cluster.site
643+
644+
mock_obj = MagicMock()
645+
mock_obj.cluster = mock_cluster
646+
# Remove site attribute from obj
647+
del mock_obj.site
648+
649+
labels = LabelDict()
650+
extract_cluster(mock_obj, labels)
651+
652+
self.assertEqual(labels.get("cluster"), "MockCluster")
653+
self.assertEqual(labels.get("cluster_group"), "Mock Group")
654+
self.assertEqual(labels.get("cluster_type"), "Mock Type")
655+
# In NetBox 4.2+, cluster scope is labeled as 'cluster_scope'
656+
self.assertEqual(labels.get("cluster_scope"), "Scoped Site")
657+
self.assertEqual(labels.get("cluster_scope_slug"), "scoped-site")
658+
# Since scope is a Site, site labels should also be set
659+
self.assertEqual(labels.get("site"), "Scoped Site")
660+
self.assertEqual(labels.get("site_slug"), "scoped-site")

0 commit comments

Comments
 (0)