netbox-plugin-prometheus-sd 1.3.0__tar.gz → 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (19) hide show
  1. {netbox_plugin_prometheus_sd-1.3.0 → netbox_plugin_prometheus_sd-2.0.0}/PKG-INFO +1 -1
  2. {netbox_plugin_prometheus_sd-1.3.0 → netbox_plugin_prometheus_sd-2.0.0}/netbox_prometheus_sd/__init__.py +3 -6
  3. netbox_plugin_prometheus_sd-2.0.0/netbox_prometheus_sd/api/views.py +153 -0
  4. {netbox_plugin_prometheus_sd-1.3.0 → netbox_plugin_prometheus_sd-2.0.0}/netbox_prometheus_sd/filtersets.py +1 -6
  5. netbox_plugin_prometheus_sd-2.0.0/netbox_prometheus_sd/tests/test_api.py +176 -0
  6. {netbox_plugin_prometheus_sd-1.3.0 → netbox_plugin_prometheus_sd-2.0.0}/netbox_prometheus_sd/tests/test_serializers.py +59 -0
  7. {netbox_plugin_prometheus_sd-1.3.0 → netbox_plugin_prometheus_sd-2.0.0}/netbox_prometheus_sd/tests/utils.py +48 -17
  8. {netbox_plugin_prometheus_sd-1.3.0 → netbox_plugin_prometheus_sd-2.0.0}/pyproject.toml +2 -2
  9. netbox_plugin_prometheus_sd-1.3.0/netbox_prometheus_sd/api/views.py +0 -93
  10. netbox_plugin_prometheus_sd-1.3.0/netbox_prometheus_sd/tests/test_api.py +0 -102
  11. {netbox_plugin_prometheus_sd-1.3.0 → netbox_plugin_prometheus_sd-2.0.0}/LICENSE +0 -0
  12. {netbox_plugin_prometheus_sd-1.3.0 → netbox_plugin_prometheus_sd-2.0.0}/README.md +0 -0
  13. {netbox_plugin_prometheus_sd-1.3.0 → netbox_plugin_prometheus_sd-2.0.0}/netbox_prometheus_sd/api/__init__.py +0 -0
  14. {netbox_plugin_prometheus_sd-1.3.0 → netbox_plugin_prometheus_sd-2.0.0}/netbox_prometheus_sd/api/serializers.py +0 -0
  15. {netbox_plugin_prometheus_sd-1.3.0 → netbox_plugin_prometheus_sd-2.0.0}/netbox_prometheus_sd/api/urls.py +0 -0
  16. {netbox_plugin_prometheus_sd-1.3.0 → netbox_plugin_prometheus_sd-2.0.0}/netbox_prometheus_sd/api/utils.py +0 -0
  17. {netbox_plugin_prometheus_sd-1.3.0 → netbox_plugin_prometheus_sd-2.0.0}/netbox_prometheus_sd/tests/__init__.py +0 -0
  18. {netbox_plugin_prometheus_sd-1.3.0 → netbox_plugin_prometheus_sd-2.0.0}/netbox_prometheus_sd/tests/test_filtersets.py +0 -0
  19. {netbox_plugin_prometheus_sd-1.3.0 → netbox_plugin_prometheus_sd-2.0.0}/netbox_prometheus_sd/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: netbox-plugin-prometheus-sd
3
- Version: 1.3.0
3
+ Version: 2.0.0
4
4
  Summary: A Netbox plugin to provide Netbox entires to Prometheus HTTP service discovery
5
5
  License: MIT
6
6
  Author: Felix Peters
@@ -1,11 +1,7 @@
1
- try:
2
- from netbox.plugins import PluginConfig
3
- except ImportError:
4
- # Fallback for old NetBox versions < 4.0
5
- from extras.plugins import PluginConfig
1
+ from netbox.plugins import PluginConfig
6
2
 
7
3
  # Placeholder for semantic release
8
- __VERSION__ = "1.3.0"
4
+ __VERSION__ = "2.0.0"
9
5
 
10
6
 
11
7
  class PrometheusSD(PluginConfig):
@@ -20,6 +16,7 @@ class PrometheusSD(PluginConfig):
20
16
  base_url = "prometheus-sd"
21
17
  required_settings = []
22
18
  default_settings = {}
19
+ min_version = "4.0.0"
23
20
 
24
21
 
25
22
  config = PrometheusSD
@@ -0,0 +1,153 @@
1
+ from django.contrib.contenttypes.prefetch import GenericPrefetch
2
+
3
+ from ipam.models import IPAddress, Service
4
+ from virtualization.models import VirtualMachine
5
+ from dcim.models.devices import Device
6
+
7
+ from netbox.api.viewsets import BaseViewSet
8
+ from netbox.api.viewsets.mixins import CustomFieldsMixin
9
+ from rest_framework.mixins import ListModelMixin
10
+
11
+ from .utils import NETBOX_RELEASE_CURRENT, NETBOX_RELEASE_41
12
+
13
+ from ipam.filtersets import IPAddressFilterSet
14
+ from dcim.filtersets import DeviceFilterSet
15
+ from virtualization.filtersets import VirtualMachineFilterSet
16
+
17
+ from ..filtersets import ServiceFilterSet
18
+ from .serializers import (
19
+ PrometheusIPAddressSerializer,
20
+ PrometheusDeviceSerializer,
21
+ PrometheusVirtualMachineSerializer,
22
+ PrometheusServiceSerializer,
23
+ )
24
+
25
+
26
+ # Netbox MosdelViewSet with list only
27
+ class NetboxPrometheusSDModelViewSet(CustomFieldsMixin, ListModelMixin, BaseViewSet):
28
+ pass
29
+
30
+
31
+ # Netbox 4.2 replaced the direct Cluster.site FK with a generic `scope` relation.
32
+ # It stays in prefetch_related either way: a GenericForeignKey cannot be JOINed
33
+ # via select_related.
34
+ if NETBOX_RELEASE_CURRENT > NETBOX_RELEASE_41:
35
+ CLUSTER_SCOPE = "cluster__scope"
36
+ else:
37
+ CLUSTER_SCOPE = "cluster__site"
38
+
39
+ # Netbox 4.3 replaced Service.device/Service.virtual_machine with a single
40
+ # generic `parent` relation, exposed via parent_object_type/parent_object_id.
41
+ # Note: hasattr(Service, "device") is NOT a usable check — Device and
42
+ # VirtualMachine declare `services = GenericRelation(..., related_query_name=...)`,
43
+ # which makes Service.device resolve to a read-only reverse accessor on every
44
+ # Netbox version, including 4.3+.
45
+ SERVICE_HAS_GENERIC_PARENT = hasattr(Service, "parent_object_type")
46
+
47
+ # Relations read off `obj.cluster` by utils.extract_cluster(), and off
48
+ # `obj.contacts` by utils.extract_contacts(). Shared by the device and VM
49
+ # querysets, which run the same extractors.
50
+ CLUSTER_RELATED = ("cluster", "cluster__group", "cluster__type")
51
+ CONTACTS_RELATED = ("contacts__contact", "contacts__role")
52
+
53
+
54
+ class ServiceViewSet(NetboxPrometheusSDModelViewSet):
55
+ # utils.extract_parent() walks the service's parent device/VM and pulls its
56
+ # name, IPs, tenant, cluster and contacts, so the parent's own relations have
57
+ # to be fetched too or every service row re-queries them.
58
+ _parent_related = (
59
+ "tenant__group",
60
+ "primary_ip4",
61
+ "primary_ip6",
62
+ "site",
63
+ *CLUSTER_RELATED,
64
+ CLUSTER_SCOPE,
65
+ *CONTACTS_RELATED,
66
+ )
67
+
68
+ if SERVICE_HAS_GENERIC_PARENT: # Netbox >= 4.3
69
+ queryset = Service.objects.prefetch_related(
70
+ GenericPrefetch(
71
+ "parent",
72
+ [
73
+ Device.objects.prefetch_related(*_parent_related, "oob_ip"),
74
+ VirtualMachine.objects.prefetch_related(*_parent_related),
75
+ ],
76
+ ),
77
+ "ipaddresses",
78
+ "tags",
79
+ )
80
+ else: # Netbox < 4.3
81
+ queryset = Service.objects.prefetch_related(
82
+ *(f"device__{rel}" for rel in _parent_related),
83
+ "device__oob_ip",
84
+ *(f"virtual_machine__{rel}" for rel in _parent_related),
85
+ "device",
86
+ "virtual_machine",
87
+ "ipaddresses",
88
+ "tags",
89
+ )
90
+ filterset_class = ServiceFilterSet
91
+ serializer_class = PrometheusServiceSerializer
92
+ pagination_class = None
93
+
94
+
95
+ class VirtualMachineViewSet(NetboxPrometheusSDModelViewSet):
96
+ queryset = (
97
+ VirtualMachine.objects.select_related(
98
+ "role",
99
+ "tenant__group",
100
+ "platform",
101
+ "primary_ip4",
102
+ "primary_ip6",
103
+ # utils.extract_cluster() falls through to obj.site for anything that
104
+ # has one, and VMs do on every supported Netbox release.
105
+ "site",
106
+ )
107
+ .prefetch_related(
108
+ CLUSTER_SCOPE,
109
+ *CLUSTER_RELATED,
110
+ *CONTACTS_RELATED,
111
+ "tags",
112
+ "services",
113
+ )
114
+ .annotate_config_context_data()
115
+ )
116
+ filterset_class = VirtualMachineFilterSet
117
+ serializer_class = PrometheusVirtualMachineSerializer
118
+ pagination_class = None
119
+
120
+
121
+ class DeviceViewSet(NetboxPrometheusSDModelViewSet):
122
+ queryset = (
123
+ Device.objects.select_related(
124
+ "device_type",
125
+ "role",
126
+ "tenant__group",
127
+ "platform",
128
+ "site",
129
+ "location",
130
+ "rack",
131
+ "primary_ip4",
132
+ "primary_ip6",
133
+ "oob_ip",
134
+ )
135
+ .prefetch_related(
136
+ CLUSTER_SCOPE,
137
+ *CLUSTER_RELATED,
138
+ *CONTACTS_RELATED,
139
+ "tags",
140
+ "services",
141
+ )
142
+ .annotate_config_context_data()
143
+ )
144
+ filterset_class = DeviceFilterSet
145
+ serializer_class = PrometheusDeviceSerializer
146
+ pagination_class = None
147
+
148
+
149
+ class IPAddressViewSet(NetboxPrometheusSDModelViewSet):
150
+ queryset = IPAddress.objects.select_related("tenant__group").prefetch_related("tags")
151
+ serializer_class = PrometheusIPAddressSerializer
152
+ filterset_class = IPAddressFilterSet
153
+ pagination_class = None
@@ -1,5 +1,3 @@
1
- # Filtersets have been renamed, we support both
2
- # https://github.com/netbox-community/netbox/commit/1024782b9e0abb48f6da65f8248741227d53dbed#diff-d9224204dab475bbe888868c02235b8ef10f07c9201c45c90804d395dc161c40
3
1
  from django.db.models import Q
4
2
  from django.utils.translation import gettext as _
5
3
 
@@ -9,10 +7,7 @@ from utilities.filters import (
9
7
  NumericArrayFilter,
10
8
  )
11
9
 
12
- try:
13
- from ipam.filtersets import ServiceFilterSet as NetboxServiceFilterSet
14
- except ImportError:
15
- from ipam.filters import ServiceFilterSet as NetboxServiceFilterSet
10
+ from ipam.filtersets import ServiceFilterSet as NetboxServiceFilterSet
16
11
 
17
12
 
18
13
  class ServiceFilterSet(NetboxServiceFilterSet):
@@ -0,0 +1,176 @@
1
+ import json
2
+
3
+ from users.models import ObjectPermission, User
4
+ from core.models import ObjectType
5
+
6
+ from django.db import connection
7
+ from django.test import TestCase
8
+ from django.test.utils import CaptureQueriesContext
9
+
10
+ from rest_framework.test import APIClient
11
+ from rest_framework import status
12
+
13
+ from . import utils
14
+
15
+
16
+ class AuthenticatedApiTestCase(TestCase):
17
+ """Base class providing an API client authenticated with view permissions."""
18
+
19
+ def setUp(self):
20
+
21
+ self.client = APIClient()
22
+
23
+ # Create test user and view permissions
24
+ user = User.objects.create_user("username", "Pas$w0rd")
25
+ obj_perm = ObjectPermission(name="test", actions=["view"])
26
+ obj_perm.save()
27
+ obj_perm.users.add(user)
28
+ obj_perm.object_types.add(
29
+ ObjectType.objects.get(app_label="dcim", model="device")
30
+ )
31
+ obj_perm.object_types.add(
32
+ ObjectType.objects.get(app_label="ipam", model="ipaddress")
33
+ )
34
+ obj_perm.object_types.add(
35
+ ObjectType.objects.get(app_label="ipam", model="service")
36
+ )
37
+ obj_perm.object_types.add(
38
+ ObjectType.objects.get(app_label="virtualization", model="virtualmachine")
39
+ )
40
+ self.client.force_authenticate(user)
41
+
42
+
43
+ class ApiEndpointTests(AuthenticatedApiTestCase):
44
+ """Test cases for ensuring API endpoint is working properly."""
45
+
46
+ def test_endpoint_device(self):
47
+ """Ensure device endpoint returns a valid response"""
48
+
49
+ for i in range(1, 61):
50
+ utils.build_device_full(f"api-test-{i}.example.com", i)
51
+
52
+ resp = self.client.get("/api/plugins/prometheus-sd/devices/")
53
+ self.assertEqual(resp.status_code, status.HTTP_200_OK)
54
+ data = json.loads(resp.content)
55
+
56
+ self.assertIsNotNone(data[0]["targets"])
57
+ self.assertIsNotNone(data[0]["labels"])
58
+ self.assertEqual(len(data), 60)
59
+
60
+ # Serializer tests render the in-memory fixture object, so they cannot
61
+ # catch a fixture that sets an attribute without persisting it. Assert
62
+ # these here, where the objects come back out of the database.
63
+ labels = {d["labels"]["__meta_netbox_name"]: d["labels"] for d in data}
64
+ first = labels["api-test-1.example.com"]
65
+ self.assertEqual(first.get("__meta_netbox_rack_u_position"), "1.0")
66
+ self.assertEqual(first.get("__meta_netbox_contact_primary_name"), "Jane Doe")
67
+ self.assertEqual(first.get("__meta_netbox_contact_primary_role"), "On Call")
68
+
69
+ def test_endpoint_virtual_machine(self):
70
+ """Ensure virtual machine endpoint returns a valid response"""
71
+
72
+ for i in range(1, 61):
73
+ utils.build_vm_full(f"api-test-vm-{i}.example.com", i)
74
+
75
+ resp = self.client.get("/api/plugins/prometheus-sd/virtual-machines/")
76
+ self.assertEqual(resp.status_code, status.HTTP_200_OK)
77
+ data = json.loads(resp.content)
78
+
79
+ self.assertIsNotNone(data[0]["targets"])
80
+ self.assertIsNotNone(data[0]["labels"])
81
+ # Full vm contains two entry in the config context so we have to double the number of vm
82
+ self.assertEqual(len(data), 120)
83
+
84
+ labels = {d["labels"]["__meta_netbox_name"]: d["labels"] for d in data}
85
+ first = labels["api-test-vm-1.example.com"]
86
+ self.assertEqual(first.get("__meta_netbox_contact_primary_name"), "Jane Doe")
87
+ self.assertEqual(first.get("__meta_netbox_contact_primary_role"), "On Call")
88
+
89
+ def test_endpoint_ip_address(self):
90
+ """Ensure ip address endpoint returns a valid response"""
91
+
92
+ for i in range(1, 61):
93
+ utils.build_full_ip(address=f"10.10.10.{i}/24")
94
+
95
+ resp = self.client.get("/api/plugins/prometheus-sd/ip-addresses/")
96
+ self.assertEqual(resp.status_code, status.HTTP_200_OK)
97
+ data = json.loads(resp.content)
98
+
99
+ self.assertIsNotNone(data[0]["targets"])
100
+ self.assertIsNotNone(data[0]["labels"])
101
+ self.assertEqual(len(data), 60)
102
+
103
+ def test_endpoint_service(self):
104
+ """Ensure service endpoint returns a valid response"""
105
+
106
+ for i in range(1, 61):
107
+ utils.build_vm_full(f"api-test-vm-{i}.example.com", i)
108
+
109
+ resp = self.client.get("/api/plugins/prometheus-sd/services/")
110
+ self.assertEqual(resp.status_code, status.HTTP_200_OK)
111
+ data = json.loads(resp.content)
112
+
113
+ self.assertIsNotNone(data[0]["targets"])
114
+ self.assertIsNotNone(data[0]["labels"])
115
+ self.assertEqual(len(data), 60)
116
+
117
+
118
+ class ApiQueryCountTests(AuthenticatedApiTestCase):
119
+ """Regression tests for the N+1 queries reported in issue #265.
120
+
121
+ These endpoints are unpaginated, so a relation that is read by a serializer
122
+ but not prefetched costs one query per row. Rather than assert an exact
123
+ query count (which legitimately shifts between Netbox releases), assert that
124
+ the count does not grow when the number of objects grows -- that is the
125
+ property an N+1 breaks.
126
+ """
127
+
128
+ def assertQueryCountDoesNotScale(self, url, build, first=5, second=15):
129
+ for i in range(1, first + 1):
130
+ build(i)
131
+
132
+ # Warm up caches (content types, permissions) that are populated on the
133
+ # first request and would otherwise be counted only in the small run.
134
+ self.assertEqual(self.client.get(url).status_code, status.HTTP_200_OK)
135
+
136
+ with CaptureQueriesContext(connection) as few_objects:
137
+ self.assertEqual(self.client.get(url).status_code, status.HTTP_200_OK)
138
+
139
+ for i in range(first + 1, second + 1):
140
+ build(i)
141
+
142
+ with CaptureQueriesContext(connection) as many_objects:
143
+ self.assertEqual(self.client.get(url).status_code, status.HTTP_200_OK)
144
+
145
+ self.assertEqual(
146
+ len(few_objects),
147
+ len(many_objects),
148
+ f"Query count for {url} grew from {len(few_objects)} queries with "
149
+ f"{first} objects to {len(many_objects)} with {second}. A relation "
150
+ f"used by the serializer is likely missing from the queryset in "
151
+ f"api/views.py.",
152
+ )
153
+
154
+ def test_device_endpoint_query_count_does_not_scale(self):
155
+ self.assertQueryCountDoesNotScale(
156
+ "/api/plugins/prometheus-sd/devices/",
157
+ lambda i: utils.build_device_full(f"query-count-{i}.example.com", i),
158
+ )
159
+
160
+ def test_virtual_machine_endpoint_query_count_does_not_scale(self):
161
+ self.assertQueryCountDoesNotScale(
162
+ "/api/plugins/prometheus-sd/virtual-machines/",
163
+ lambda i: utils.build_vm_full(f"query-count-vm-{i}.example.com", i),
164
+ )
165
+
166
+ def test_service_endpoint_query_count_does_not_scale(self):
167
+ self.assertQueryCountDoesNotScale(
168
+ "/api/plugins/prometheus-sd/services/",
169
+ lambda i: utils.build_vm_full(f"query-count-svc-{i}.example.com", i),
170
+ )
171
+
172
+ def test_ip_address_endpoint_query_count_does_not_scale(self):
173
+ self.assertQueryCountDoesNotScale(
174
+ "/api/plugins/prometheus-sd/ip-addresses/",
175
+ lambda i: utils.build_full_ip(address=f"10.10.20.{i}/24"),
176
+ )
@@ -198,6 +198,32 @@ class PrometheusVirtualMachineSerializerTests(TestCase):
198
198
  data["labels"],
199
199
  )
200
200
  )
201
+ # Tags are compared as sets: the label is a joined list whose order
202
+ # is not guaranteed.
203
+ self.assertEqual(
204
+ set(data["labels"]["__meta_netbox_tags"].split(",")),
205
+ {"Tag1", "Tag 2"},
206
+ )
207
+ self.assertEqual(
208
+ set(data["labels"]["__meta_netbox_tag_slugs"].split(",")),
209
+ {"tag1", "tag-2"},
210
+ )
211
+ self.assertTrue(
212
+ utils.dictContainsSubset(
213
+ {"__meta_netbox_services": "ssh"}, data["labels"]
214
+ )
215
+ )
216
+ self.assertTrue(
217
+ utils.dictContainsSubset(
218
+ {
219
+ "__meta_netbox_contact_primary_name": "Jane Doe",
220
+ "__meta_netbox_contact_primary_email": "jane@example.com",
221
+ "__meta_netbox_contact_primary_comments": "Primary on-call",
222
+ "__meta_netbox_contact_primary_role": "On Call",
223
+ },
224
+ data["labels"],
225
+ )
226
+ )
201
227
 
202
228
 
203
229
  class PrometheusDeviceSerializerTests(TestCase):
@@ -356,6 +382,29 @@ class PrometheusDeviceSerializerTests(TestCase):
356
382
  {"__meta_netbox_rack_u_position": "1.0"}, data["labels"]
357
383
  )
358
384
  )
385
+ # Tags are compared as sets: the label is a joined list whose order is
386
+ # not guaranteed.
387
+ self.assertEqual(
388
+ set(data["labels"]["__meta_netbox_tags"].split(",")), {"Tag1", "Tag 2"}
389
+ )
390
+ self.assertEqual(
391
+ set(data["labels"]["__meta_netbox_tag_slugs"].split(",")),
392
+ {"tag1", "tag-2"},
393
+ )
394
+ self.assertTrue(
395
+ utils.dictContainsSubset({"__meta_netbox_services": "ssh"}, data["labels"])
396
+ )
397
+ self.assertTrue(
398
+ utils.dictContainsSubset(
399
+ {
400
+ "__meta_netbox_contact_primary_name": "Jane Doe",
401
+ "__meta_netbox_contact_primary_email": "jane@example.com",
402
+ "__meta_netbox_contact_primary_comments": "Primary on-call",
403
+ "__meta_netbox_contact_primary_role": "On Call",
404
+ },
405
+ data["labels"],
406
+ )
407
+ )
359
408
 
360
409
 
361
410
  class PrometheusIPAddressSerializerTests(TestCase):
@@ -493,6 +542,11 @@ class PrometheusServiceSerializerTests(TestCase):
493
542
  {"__meta_netbox_primary_ip6": "2001:db8:1701::2"}, data["labels"]
494
543
  )
495
544
  )
545
+ self.assertTrue(
546
+ utils.dictContainsSubset(
547
+ {"__meta_netbox_ipaddresses": "192.168.0.1"}, data["labels"]
548
+ )
549
+ )
496
550
 
497
551
  def test_vm_service_full_to_target(self):
498
552
  vm = utils.build_vm_full("vm-full-01.example.com")
@@ -576,3 +630,8 @@ class PrometheusServiceSerializerTests(TestCase):
576
630
  {"__meta_netbox_primary_ip6": "2001:db8:1701::2"}, data["labels"]
577
631
  )
578
632
  )
633
+ self.assertTrue(
634
+ utils.dictContainsSubset(
635
+ {"__meta_netbox_ipaddresses": "192.168.0.1"}, data["labels"]
636
+ )
637
+ )
@@ -7,7 +7,13 @@ from dcim.models import Device, DeviceRole, Platform, Rack
7
7
  from extras.models import ConfigContext, Tag
8
8
 
9
9
  from ipam.models import IPAddress, Service
10
- from tenancy.models import Tenant, TenantGroup
10
+ from tenancy.models import (
11
+ Contact,
12
+ ContactAssignment,
13
+ ContactRole,
14
+ Tenant,
15
+ TenantGroup,
16
+ )
11
17
 
12
18
  from virtualization.models import (
13
19
  Cluster,
@@ -53,6 +59,18 @@ def build_tenant():
53
59
  return Tenant.objects.get_or_create(name="Acme Corp.", slug="acme")[0]
54
60
 
55
61
 
62
+ def build_contact_for(obj):
63
+ """Assign a contact to obj so extract_contacts() has something to render."""
64
+ contact = Contact.objects.get_or_create(
65
+ name="Jane Doe",
66
+ defaults={"email": "jane@example.com", "comments": "Primary on-call"},
67
+ )[0]
68
+ role = ContactRole.objects.get_or_create(name="On Call", slug="on-call")[0]
69
+ return ContactAssignment.objects.create(
70
+ object=obj, contact=contact, role=role, priority="primary"
71
+ )
72
+
73
+
56
74
  def build_custom_fields():
57
75
  """Build custom field definition with different kinds of custom values"""
58
76
  return {
@@ -114,15 +132,29 @@ def build_vm_full(name, ip_octet=1):
114
132
  vm.tags.add("Tag 2")
115
133
  vm.save()
116
134
 
117
- try: # NetBox 4.2+
118
- Service.objects.create(parent=vm, name="ssh", protocol="tcp", ports=[22])
119
- except AttributeError: # NetBox <4.2
120
- Service.objects.create(virtual_machine=vm, name="ssh", protocol="tcp", ports=[22])
135
+ build_contact_for(vm)
136
+ build_service_for(vm, name="ssh", protocol="tcp", ports=[22])
121
137
  return vm
122
138
 
123
139
 
140
+ def build_service_for(parent, **kwargs):
141
+ """Create a service bound to parent, with the parent's primary IPv4 attached.
142
+
143
+ Netbox 4.3 replaced Service.device/Service.virtual_machine with a single
144
+ generic `parent` relation, which is read-only on older releases.
145
+ """
146
+ try: # NetBox 4.3+
147
+ service = Service.objects.create(parent=parent, **kwargs)
148
+ except AttributeError: # NetBox <4.3
149
+ field = "device" if isinstance(parent, Device) else "virtual_machine"
150
+ service = Service.objects.create(**{field: parent}, **kwargs)
151
+
152
+ if parent.primary_ip4 is not None:
153
+ service.ipaddresses.set([parent.primary_ip4])
154
+ return service
155
+
156
+
124
157
  def build_minimal_device(name):
125
- role_attr = "role" if hasattr(Device, "role") else "device_role"
126
158
  return Device.objects.get_or_create(
127
159
  name=name,
128
160
  device_type=DeviceType.objects.get_or_create(
@@ -133,11 +165,7 @@ def build_minimal_device(name):
133
165
  )[0],
134
166
  )[0],
135
167
  site=Site.objects.get_or_create(name="Site", slug="site")[0],
136
- **{
137
- role_attr: DeviceRole.objects.get_or_create(
138
- name="Firewall", slug="firewall"
139
- )[0],
140
- },
168
+ role=DeviceRole.objects.get_or_create(name="Firewall", slug="firewall")[0],
141
169
  )[0]
142
170
 
143
171
 
@@ -225,17 +253,20 @@ def build_device_full(name, ip_octet=1):
225
253
  )[0]
226
254
  device.oob_ip = IPAddress.objects.get_or_create(address=f"10.0.0.{ip_octet}/24")[0]
227
255
  device.rack = Rack.objects.get_or_create(
228
- name="R01B01", site=Site.objects.get_or_create(name="Site", slug="site")[0]
256
+ name="R01B01",
257
+ site=Site.objects.get_or_create(name="Site", slug="site")[0],
258
+ defaults={"u_height": 100},
229
259
  )[0]
230
260
  device.site = Site.objects.get_or_create(name="Site", slug="site")[0]
261
+ # Rack position is unique per (rack, position, face), so it has to follow
262
+ # ip_octet like the addresses do -- otherwise callers building more than one
263
+ # device hit an IntegrityError.
264
+ device.position = float(ip_octet)
231
265
  device.tags.add("Tag1")
232
266
  device.tags.add("Tag 2")
233
267
  device.save()
234
- device.position = 1.0
235
- try: # NetBox 4.2+
236
- Service.objects.create(parent=device, name="ssh", protocol="tcp", ports=[22])
237
- except AttributeError: # NetBox <4.2
238
- Service.objects.create(device=device, name="ssh", protocol="tcp", ports=[22])
268
+ build_contact_for(device)
269
+ build_service_for(device, name="ssh", protocol="tcp", ports=[22])
239
270
  return device
240
271
 
241
272
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "netbox-plugin-prometheus-sd"
3
- version = "v1.3.0" # placeholder
3
+ version = "v2.0.0" # placeholder
4
4
  description = "A Netbox plugin to provide Netbox entires to Prometheus HTTP service discovery"
5
5
  authors = ["Felix Peters <felix.peters@breuninger.de>"]
6
6
  license = "MIT"
@@ -14,7 +14,7 @@ packages = [
14
14
  python = ">=3.10,<4.0"
15
15
 
16
16
  [tool.poetry.group.dev.dependencies]
17
- invoke = "^2.2.0"
17
+ invoke = ">=2.2,<4.0"
18
18
  docker = "^7.1.0"
19
19
  testcontainers = {extras = ["postgres", "redis", "generic"], version = "^4.14.1"}
20
20
  python-semantic-release = "^10.5.3"
@@ -1,93 +0,0 @@
1
- from ipam.models import IPAddress, Service
2
- from virtualization.models import VirtualMachine
3
- from dcim.models.devices import Device
4
-
5
- from netbox.api.viewsets import BaseViewSet
6
- from netbox.api.viewsets.mixins import CustomFieldsMixin
7
- from rest_framework.mixins import ListModelMixin
8
-
9
- from .utils import NETBOX_RELEASE_CURRENT, NETBOX_RELEASE_41
10
-
11
- # Filtersets have been renamed, we support both
12
- # https://github.com/netbox-community/netbox/commit/1024782b9e0abb48f6da65f8248741227d53dbed#diff-d9224204dab475bbe888868c02235b8ef10f07c9201c45c90804d395dc161c40
13
- try:
14
- from ipam.filtersets import IPAddressFilterSet
15
- from dcim.filtersets import DeviceFilterSet
16
- from virtualization.filtersets import VirtualMachineFilterSet
17
- except ImportError:
18
- from ipam.filters import IPAddressFilterSet
19
- from dcim.filters import DeviceFilterSet
20
- from virtualization.filters import VirtualMachineFilterSet
21
-
22
- from ..filtersets import ServiceFilterSet
23
- from .serializers import (
24
- PrometheusIPAddressSerializer,
25
- PrometheusDeviceSerializer,
26
- PrometheusVirtualMachineSerializer,
27
- PrometheusServiceSerializer,
28
- )
29
-
30
-
31
- # Netbox MosdelViewSet with list only
32
- class NetboxPrometheusSDModelViewSet(CustomFieldsMixin, ListModelMixin, BaseViewSet):
33
- pass
34
-
35
-
36
- class ServiceViewSet(NetboxPrometheusSDModelViewSet):
37
- queryset = Service.objects.prefetch_related(
38
- "device",
39
- "virtual_machine",
40
- "ipaddresses",
41
- "tags",
42
- )
43
- filterset_class = ServiceFilterSet
44
- serializer_class = PrometheusServiceSerializer
45
- pagination_class = None
46
-
47
-
48
- class VirtualMachineViewSet(NetboxPrometheusSDModelViewSet):
49
- if NETBOX_RELEASE_CURRENT > NETBOX_RELEASE_41:
50
- cluster_scope = "cluster__scope"
51
- else:
52
- cluster_scope = "cluster__site"
53
- queryset = VirtualMachine.objects.prefetch_related(
54
- cluster_scope,
55
- "role",
56
- "tenant",
57
- "platform",
58
- "primary_ip4",
59
- "primary_ip6",
60
- "tags",
61
- "services",
62
- "contacts",
63
- )
64
- filterset_class = VirtualMachineFilterSet
65
- serializer_class = PrometheusVirtualMachineSerializer
66
- pagination_class = None
67
-
68
-
69
- class DeviceViewSet(NetboxPrometheusSDModelViewSet):
70
- queryset = Device.objects.prefetch_related(
71
- "device_type__manufacturer",
72
- "role" if hasattr(Device, "role") else "device_role",
73
- "tenant",
74
- "platform",
75
- "site",
76
- "location",
77
- "rack",
78
- "parent_bay",
79
- "virtual_chassis__master",
80
- "primary_ip4__nat_outside",
81
- "primary_ip6__nat_outside",
82
- "tags",
83
- )
84
- filterset_class = DeviceFilterSet
85
- serializer_class = PrometheusDeviceSerializer
86
- pagination_class = None
87
-
88
-
89
- class IPAddressViewSet(NetboxPrometheusSDModelViewSet):
90
- queryset = IPAddress.objects.prefetch_related("tenant", "tags")
91
- serializer_class = PrometheusIPAddressSerializer
92
- filterset_class = IPAddressFilterSet
93
- pagination_class = None
@@ -1,102 +0,0 @@
1
- import json
2
-
3
- try:
4
- from users.models import ObjectPermission
5
- from users.models import User
6
- from core.models import ObjectType
7
-
8
- except ImportError:
9
- # Fallback for old NetBox versions < 4.0
10
- from django.contrib.contenttypes.models import ContentType as ObjectType
11
- from django.contrib.auth.models import User
12
-
13
- from django.test import TestCase
14
-
15
- from rest_framework.test import APIClient
16
- from rest_framework import status
17
-
18
- from . import utils
19
-
20
-
21
- class ApiEndpointTests(TestCase):
22
- """Test cases for ensuring API endpoint is working properly."""
23
-
24
- def setUp(self):
25
-
26
- self.client = APIClient()
27
-
28
- # Create test user and view permissions
29
- user = User.objects.create_user("username", "Pas$w0rd")
30
- obj_perm = ObjectPermission(name="test", actions=["view"])
31
- obj_perm.save()
32
- obj_perm.users.add(user)
33
- obj_perm.object_types.add(
34
- ObjectType.objects.get(app_label="dcim", model="device")
35
- )
36
- obj_perm.object_types.add(
37
- ObjectType.objects.get(app_label="ipam", model="ipaddress")
38
- )
39
- obj_perm.object_types.add(
40
- ObjectType.objects.get(app_label="ipam", model="service")
41
- )
42
- obj_perm.object_types.add(
43
- ObjectType.objects.get(app_label="virtualization", model="virtualmachine")
44
- )
45
- self.client.force_authenticate(user)
46
-
47
- def test_endpoint_device(self):
48
- """Ensure device endpoint returns a valid response"""
49
-
50
- for i in range(1, 61):
51
- utils.build_device_full(f"api-test-{i}.example.com", i)
52
-
53
- resp = self.client.get("/api/plugins/prometheus-sd/devices/")
54
- self.assertEqual(resp.status_code, status.HTTP_200_OK)
55
- data = json.loads(resp.content)
56
-
57
- self.assertIsNotNone(data[0]["targets"])
58
- self.assertIsNotNone(data[0]["labels"])
59
- self.assertEqual(len(data), 60)
60
-
61
- def test_endpoint_virtual_machine(self):
62
- """Ensure virtual machine endpoint returns a valid response"""
63
-
64
- for i in range(1, 61):
65
- utils.build_vm_full(f"api-test-vm-{i}.example.com", i)
66
-
67
- resp = self.client.get("/api/plugins/prometheus-sd/virtual-machines/")
68
- self.assertEqual(resp.status_code, status.HTTP_200_OK)
69
- data = json.loads(resp.content)
70
-
71
- self.assertIsNotNone(data[0]["targets"])
72
- self.assertIsNotNone(data[0]["labels"])
73
- # Full vm contains two entry in the config context so we have to double the number of vm
74
- self.assertEqual(len(data), 120)
75
-
76
- def test_endpoint_ip_address(self):
77
- """Ensure ip address endpoint returns a valid response"""
78
-
79
- for i in range(1, 61):
80
- utils.build_full_ip(address=f"10.10.10.{i}/24")
81
-
82
- resp = self.client.get("/api/plugins/prometheus-sd/ip-addresses/")
83
- self.assertEqual(resp.status_code, status.HTTP_200_OK)
84
- data = json.loads(resp.content)
85
-
86
- self.assertIsNotNone(data[0]["targets"])
87
- self.assertIsNotNone(data[0]["labels"])
88
- self.assertEqual(len(data), 60)
89
-
90
- def test_endpoint_service(self):
91
- """Ensure service endpoint returns a valid response"""
92
-
93
- for i in range(1, 61):
94
- utils.build_vm_full(f"api-test-vm-{i}.example.com", i)
95
-
96
- resp = self.client.get("/api/plugins/prometheus-sd/services/")
97
- self.assertEqual(resp.status_code, status.HTTP_200_OK)
98
- data = json.loads(resp.content)
99
-
100
- self.assertIsNotNone(data[0]["targets"])
101
- self.assertIsNotNone(data[0]["labels"])
102
- self.assertEqual(len(data), 60)