netbox-plugin-prometheus-sd 1.2.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 (20) hide show
  1. {netbox_plugin_prometheus_sd-1.2.0 → netbox_plugin_prometheus_sd-2.0.0}/PKG-INFO +12 -18
  2. {netbox_plugin_prometheus_sd-1.2.0 → netbox_plugin_prometheus_sd-2.0.0}/README.md +10 -15
  3. {netbox_plugin_prometheus_sd-1.2.0 → netbox_plugin_prometheus_sd-2.0.0}/netbox_prometheus_sd/__init__.py +3 -6
  4. {netbox_plugin_prometheus_sd-1.2.0 → netbox_plugin_prometheus_sd-2.0.0}/netbox_prometheus_sd/api/serializers.py +0 -5
  5. netbox_plugin_prometheus_sd-2.0.0/netbox_prometheus_sd/api/views.py +153 -0
  6. {netbox_plugin_prometheus_sd-1.2.0 → netbox_plugin_prometheus_sd-2.0.0}/netbox_prometheus_sd/filtersets.py +1 -6
  7. netbox_plugin_prometheus_sd-2.0.0/netbox_prometheus_sd/tests/test_api.py +176 -0
  8. {netbox_plugin_prometheus_sd-1.2.0 → netbox_plugin_prometheus_sd-2.0.0}/netbox_prometheus_sd/tests/test_serializers.py +59 -0
  9. {netbox_plugin_prometheus_sd-1.2.0 → netbox_plugin_prometheus_sd-2.0.0}/netbox_prometheus_sd/tests/utils.py +48 -11
  10. netbox_plugin_prometheus_sd-2.0.0/pyproject.toml +39 -0
  11. netbox_plugin_prometheus_sd-1.2.0/netbox_prometheus_sd/api/views.py +0 -107
  12. netbox_plugin_prometheus_sd-1.2.0/netbox_prometheus_sd/tests/test_api.py +0 -102
  13. netbox_plugin_prometheus_sd-1.2.0/pyproject.toml +0 -22
  14. {netbox_plugin_prometheus_sd-1.2.0 → netbox_plugin_prometheus_sd-2.0.0}/LICENSE +0 -0
  15. {netbox_plugin_prometheus_sd-1.2.0 → netbox_plugin_prometheus_sd-2.0.0}/netbox_prometheus_sd/api/__init__.py +0 -0
  16. {netbox_plugin_prometheus_sd-1.2.0 → netbox_plugin_prometheus_sd-2.0.0}/netbox_prometheus_sd/api/urls.py +0 -0
  17. {netbox_plugin_prometheus_sd-1.2.0 → netbox_plugin_prometheus_sd-2.0.0}/netbox_prometheus_sd/api/utils.py +0 -0
  18. {netbox_plugin_prometheus_sd-1.2.0 → netbox_plugin_prometheus_sd-2.0.0}/netbox_prometheus_sd/tests/__init__.py +0 -0
  19. {netbox_plugin_prometheus_sd-1.2.0 → netbox_plugin_prometheus_sd-2.0.0}/netbox_prometheus_sd/tests/test_filtersets.py +0 -0
  20. {netbox_plugin_prometheus_sd-1.2.0 → netbox_plugin_prometheus_sd-2.0.0}/netbox_prometheus_sd/tests/test_utils.py +0 -0
@@ -1,14 +1,13 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: netbox-plugin-prometheus-sd
3
- Version: 1.2.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
7
7
  Author-email: felix.peters@breuninger.de
8
- Requires-Python: >=3.8.1,<4.0
8
+ Requires-Python: >=3.10,<4.0
9
9
  Classifier: License :: OSI Approved :: MIT License
10
10
  Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.9
12
11
  Classifier: Programming Language :: Python :: 3.10
13
12
  Classifier: Programming Language :: Python :: 3.11
14
13
  Classifier: Programming Language :: Python :: 3.12
@@ -110,7 +109,7 @@ Netbox content is created by using Netbox docker initializers.
110
109
  The demo data doesn't make sense, but they are good enough for demonstrating how to configure Prometheus
111
110
  and get demo data to Prometheus service discovery.
112
111
 
113
- Go to the `example` folder and run `docker-compose up`. Prometheus should get available on `http://localhost:9090`.
112
+ Go to the `example` folder and run `docker-compose up --build`. Prometheus should get available on `http://localhost:9090`.
114
113
 
115
114
  Push some example devices and objects to Netbox using the initializers:
116
115
 
@@ -122,34 +121,29 @@ Netbox content should then be available in the service discovery tab.
122
121
 
123
122
  ## Development
124
123
 
125
- We use Poetry for dependency management and invoke as task runner.
126
- As Netbox plugins cannot be tested standalone, we need invoke to start all code embedded in Netbox Docker containers.
124
+ We use [Poetry](https://python-poetry.org/) for dependency management and [invoke](https://www.pyinvoke.org/) as task runner.
125
+ To test the plugin in an isolated environment, we use [testcontainers](https://testcontainers.com/?language=python)
126
+ which creates "throwaway, lightweight" Netbox Docker containers.
127
127
 
128
128
  All code to run in docker is located under `develop`.
129
129
  To start a virtual env managed by poetry run `poetry shell`.
130
130
  All following commands are started inside this environment.
131
131
 
132
- In order to run tests invoke the test steps
132
+ In order to run tests invoke the tests:
133
133
 
134
134
  ```bash
135
- # Build Docker images
136
- invoke build
137
-
138
- # Execute all tests
139
- invoke tests
135
+ # Build the containers and execute all tests
136
+ invoke test
140
137
  ```
141
138
 
142
139
  Features should be covered by a unit test, but some times it's easier to develop on an running system.
143
140
 
144
141
  ```bash
145
- # Start a local Netbox with docker
146
- invoke start
147
-
148
- # Create an user named `admin`
149
- invoke create-user
142
+ # Start a development environment
143
+ invoke build_dev
150
144
  ```
151
145
 
152
- Visit http://localhost:8000 and log in with the new user.
146
+ Visit http://localhost:8000 and log in with the default admin credentials.
153
147
  You can now define Netbox entities and test around.
154
148
 
155
149
  API endpoints for testing can be found at http://localhost:8000/api/plugins/prometheus-sd/
@@ -93,7 +93,7 @@ Netbox content is created by using Netbox docker initializers.
93
93
  The demo data doesn't make sense, but they are good enough for demonstrating how to configure Prometheus
94
94
  and get demo data to Prometheus service discovery.
95
95
 
96
- Go to the `example` folder and run `docker-compose up`. Prometheus should get available on `http://localhost:9090`.
96
+ Go to the `example` folder and run `docker-compose up --build`. Prometheus should get available on `http://localhost:9090`.
97
97
 
98
98
  Push some example devices and objects to Netbox using the initializers:
99
99
 
@@ -105,34 +105,29 @@ Netbox content should then be available in the service discovery tab.
105
105
 
106
106
  ## Development
107
107
 
108
- We use Poetry for dependency management and invoke as task runner.
109
- As Netbox plugins cannot be tested standalone, we need invoke to start all code embedded in Netbox Docker containers.
108
+ We use [Poetry](https://python-poetry.org/) for dependency management and [invoke](https://www.pyinvoke.org/) as task runner.
109
+ To test the plugin in an isolated environment, we use [testcontainers](https://testcontainers.com/?language=python)
110
+ which creates "throwaway, lightweight" Netbox Docker containers.
110
111
 
111
112
  All code to run in docker is located under `develop`.
112
113
  To start a virtual env managed by poetry run `poetry shell`.
113
114
  All following commands are started inside this environment.
114
115
 
115
- In order to run tests invoke the test steps
116
+ In order to run tests invoke the tests:
116
117
 
117
118
  ```bash
118
- # Build Docker images
119
- invoke build
120
-
121
- # Execute all tests
122
- invoke tests
119
+ # Build the containers and execute all tests
120
+ invoke test
123
121
  ```
124
122
 
125
123
  Features should be covered by a unit test, but some times it's easier to develop on an running system.
126
124
 
127
125
  ```bash
128
- # Start a local Netbox with docker
129
- invoke start
130
-
131
- # Create an user named `admin`
132
- invoke create-user
126
+ # Start a development environment
127
+ invoke build_dev
133
128
  ```
134
129
 
135
- Visit http://localhost:8000 and log in with the new user.
130
+ Visit http://localhost:8000 and log in with the default admin credentials.
136
131
  You can now define Netbox entities and test around.
137
132
 
138
133
  API endpoints for testing can be found at http://localhost:8000/api/plugins/prometheus-sd/
@@ -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__ = "0.0.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
@@ -98,11 +98,6 @@ class PrometheusDeviceSerializer(serializers.ModelSerializer, PrometheusTargetsM
98
98
  if hasattr(obj, "role") and obj.role is not None:
99
99
  labels["role"] = obj.role.name
100
100
  labels["role_slug"] = obj.role.slug
101
- elif (
102
- hasattr(obj, "device_role") and obj.device_role is not None
103
- ): # netbox <3.6.0
104
- labels["role"] = obj.device_role.name
105
- labels["role_slug"] = obj.device_role.slug
106
101
 
107
102
  if hasattr(obj, "device_type") and obj.device_type is not None:
108
103
  labels["device_type"] = obj.device_type.model
@@ -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,12 +132,29 @@ def build_vm_full(name, ip_octet=1):
114
132
  vm.tags.add("Tag 2")
115
133
  vm.save()
116
134
 
117
- 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])
118
137
  return vm
119
138
 
120
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
+
121
157
  def build_minimal_device(name):
122
- role_attr = "role" if hasattr(Device, "role") else "device_role"
123
158
  return Device.objects.get_or_create(
124
159
  name=name,
125
160
  device_type=DeviceType.objects.get_or_create(
@@ -130,11 +165,7 @@ def build_minimal_device(name):
130
165
  )[0],
131
166
  )[0],
132
167
  site=Site.objects.get_or_create(name="Site", slug="site")[0],
133
- **{
134
- role_attr: DeviceRole.objects.get_or_create(
135
- name="Firewall", slug="firewall"
136
- )[0],
137
- },
168
+ role=DeviceRole.objects.get_or_create(name="Firewall", slug="firewall")[0],
138
169
  )[0]
139
170
 
140
171
 
@@ -222,14 +253,20 @@ def build_device_full(name, ip_octet=1):
222
253
  )[0]
223
254
  device.oob_ip = IPAddress.objects.get_or_create(address=f"10.0.0.{ip_octet}/24")[0]
224
255
  device.rack = Rack.objects.get_or_create(
225
- 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},
226
259
  )[0]
227
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)
228
265
  device.tags.add("Tag1")
229
266
  device.tags.add("Tag 2")
230
267
  device.save()
231
- device.position = 1.0
232
- 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])
233
270
  return device
234
271
 
235
272
 
@@ -0,0 +1,39 @@
1
+ [tool.poetry]
2
+ name = "netbox-plugin-prometheus-sd"
3
+ version = "v2.0.0" # placeholder
4
+ description = "A Netbox plugin to provide Netbox entires to Prometheus HTTP service discovery"
5
+ authors = ["Felix Peters <felix.peters@breuninger.de>"]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+
9
+ packages = [
10
+ { include = "netbox_prometheus_sd" },
11
+ ]
12
+
13
+ [tool.poetry.dependencies]
14
+ python = ">=3.10,<4.0"
15
+
16
+ [tool.poetry.group.dev.dependencies]
17
+ invoke = ">=2.2,<4.0"
18
+ docker = "^7.1.0"
19
+ testcontainers = {extras = ["postgres", "redis", "generic"], version = "^4.14.1"}
20
+ python-semantic-release = "^10.5.3"
21
+
22
+ [build-system]
23
+ requires = ["poetry-core>=1.0.0"]
24
+ build-backend = "poetry.core.masonry.api"
25
+
26
+ [tool.semantic_release]
27
+ commit_parser = "conventional"
28
+ version_toml = ["pyproject.toml:tool.poetry.version"]
29
+ version_variables = ["netbox_prometheus_sd/__init__.py:__VERSION__"]
30
+ build_command = "pip install poetry && poetry build"
31
+
32
+ [tool.semantic_release.branches.main]
33
+ match = "main"
34
+
35
+ [tool.semantic_release.branches.prerelease]
36
+ # Matches branches with the prefixes 'pre/'.
37
+ match = "^(pre)/.+"
38
+ prerelease = true
39
+ prerelease_token = "pre"
@@ -1,107 +0,0 @@
1
- from ipam.models import IPAddress, Service
2
- from virtualization.models import VirtualMachine
3
- from dcim.models.devices import Device
4
-
5
-
6
- try: # Netbox >= 3.5
7
- from netbox.api.viewsets import BaseViewSet
8
- from netbox.api.viewsets.mixins import CustomFieldsMixin
9
- from rest_framework.mixins import ListModelMixin
10
-
11
- # Netbox MosdelViewSet with list only
12
- class NetboxPrometheusSDModelViewSet(
13
- CustomFieldsMixin, ListModelMixin, BaseViewSet
14
- ):
15
- pass
16
-
17
- except ImportError:
18
- try: # 3.2 >= Netbox < 3.5
19
- from netbox.api.viewsets import (
20
- NetBoxModelViewSet as NetboxPrometheusSDModelViewSet,
21
- )
22
- except ImportError: # Netbox < 3.2
23
- from extras.api.views import (
24
- CustomFieldModelViewSet as NetboxPrometheusSDModelViewSet,
25
- )
26
-
27
- from .utils import NETBOX_RELEASE_CURRENT, NETBOX_RELEASE_41
28
-
29
- # Filtersets have been renamed, we support both
30
- # https://github.com/netbox-community/netbox/commit/1024782b9e0abb48f6da65f8248741227d53dbed#diff-d9224204dab475bbe888868c02235b8ef10f07c9201c45c90804d395dc161c40
31
- try:
32
- from ipam.filtersets import IPAddressFilterSet
33
- from dcim.filtersets import DeviceFilterSet
34
- from virtualization.filtersets import VirtualMachineFilterSet
35
- except ImportError:
36
- from ipam.filters import IPAddressFilterSet
37
- from dcim.filters import DeviceFilterSet
38
- from virtualization.filters import VirtualMachineFilterSet
39
-
40
-
41
- from ..filtersets import ServiceFilterSet
42
- from .serializers import (
43
- PrometheusIPAddressSerializer,
44
- PrometheusDeviceSerializer,
45
- PrometheusVirtualMachineSerializer,
46
- PrometheusServiceSerializer,
47
- )
48
-
49
-
50
- class ServiceViewSet(NetboxPrometheusSDModelViewSet):
51
- queryset = Service.objects.prefetch_related(
52
- "device",
53
- "virtual_machine",
54
- "ipaddresses",
55
- "tags",
56
- )
57
- filterset_class = ServiceFilterSet
58
- serializer_class = PrometheusServiceSerializer
59
- pagination_class = None
60
-
61
-
62
- class VirtualMachineViewSet(NetboxPrometheusSDModelViewSet):
63
- if NETBOX_RELEASE_CURRENT > NETBOX_RELEASE_41:
64
- cluster_scope = "cluster__scope"
65
- else:
66
- cluster_scope = "cluster__site"
67
- queryset = VirtualMachine.objects.prefetch_related(
68
- cluster_scope,
69
- "role",
70
- "tenant",
71
- "platform",
72
- "primary_ip4",
73
- "primary_ip6",
74
- "tags",
75
- "services",
76
- "contacts",
77
- )
78
- filterset_class = VirtualMachineFilterSet
79
- serializer_class = PrometheusVirtualMachineSerializer
80
- pagination_class = None
81
-
82
-
83
- class DeviceViewSet(NetboxPrometheusSDModelViewSet):
84
- queryset = Device.objects.prefetch_related(
85
- "device_type__manufacturer",
86
- "role" if hasattr(Device, "role") else "device_role",
87
- "tenant",
88
- "platform",
89
- "site",
90
- "location",
91
- "rack",
92
- "parent_bay",
93
- "virtual_chassis__master",
94
- "primary_ip4__nat_outside",
95
- "primary_ip6__nat_outside",
96
- "tags",
97
- )
98
- filterset_class = DeviceFilterSet
99
- serializer_class = PrometheusDeviceSerializer
100
- pagination_class = None
101
-
102
-
103
- class IPAddressViewSet(NetboxPrometheusSDModelViewSet):
104
- queryset = IPAddress.objects.prefetch_related("tenant", "tags")
105
- serializer_class = PrometheusIPAddressSerializer
106
- filterset_class = IPAddressFilterSet
107
- 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)
@@ -1,22 +0,0 @@
1
- [tool.poetry]
2
- name = "netbox-plugin-prometheus-sd"
3
- version = "v1.2.0" # placeholder
4
- description = "A Netbox plugin to provide Netbox entires to Prometheus HTTP service discovery"
5
- authors = ["Felix Peters <felix.peters@breuninger.de>"]
6
- license = "MIT"
7
- readme = "README.md"
8
-
9
- packages = [
10
- { include = "netbox_prometheus_sd" },
11
- ]
12
-
13
- [tool.poetry.dependencies]
14
- python = ">=3.8.1,<4.0"
15
-
16
- [tool.poetry.dev-dependencies]
17
- invoke = "^2.2.0"
18
- typed-ast = "^1.5.5"
19
-
20
- [build-system]
21
- requires = ["poetry-core>=1.0.0"]
22
- build-backend = "poetry.core.masonry.api"