Skip to content

Commit a5ebe02

Browse files
authored
feat: plugin configuration improvements (#30)
* feat: plugin config: diode target and disallow diode target override Signed-off-by: Michal Fiedorowicz <[email protected]> * feat: read secrets for api keys from mounted path Signed-off-by: Michal Fiedorowicz <[email protected]> * tests for views and forms Signed-off-by: Michal Fiedorowicz <[email protected]> --------- Signed-off-by: Michal Fiedorowicz <[email protected]>
1 parent e41aa8f commit a5ebe02

File tree

8 files changed

+163
-23
lines changed

8 files changed

+163
-23
lines changed

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ PLUGINS = [
4040
]
4141
```
4242

43+
Also in your `configuration.py` file, add `netbox_diode_plugin`to the `PLUGINS_CONFIG` dictionary, e.g.:
44+
45+
```python
46+
PLUGINS_CONFIG = {
47+
"netbox_diode_plugin": {
48+
"diode_target": "grpc://localhost:8080/diode", # The Diode gRPC target for communication with Diode server, default: "grpc://localhost:8080/diode"
49+
"disallow_diode_target_override": True, # Disallow the Diode target to be overridden by the user, default: False
50+
}
51+
}
52+
```
53+
4354
Restart NetBox services to load the plugin:
4455

4556
```

docker/netbox/configuration/plugins.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66

77
PLUGINS = ["netbox_diode_plugin"]
88

9-
# PLUGINS_CONFIG = {
10-
# "netbox_diode_plugin": {
11-
#
12-
# }
13-
# }
9+
PLUGINS_CONFIG = {
10+
"netbox_diode_plugin": {
11+
"diode_target": "grpc://localhost:8080/diode", # The Diode gRPC target for communication with Diode server, default: "grpc://localhost:8080/diode"
12+
"disallow_diode_target_override": False, # Disallow the Diode target to be overridden by the user, default: False
13+
}
14+
}

netbox_diode_plugin/forms.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# !/usr/bin/env python
22
# Copyright 2024 NetBox Labs Inc
33
"""Diode NetBox Plugin - Forms."""
4-
4+
from django.conf import settings as netbox_settings
55
from netbox.forms import NetBoxModelForm
66
from utilities.forms.rendering import FieldSet
77

@@ -24,3 +24,17 @@ class Meta:
2424

2525
model = Setting
2626
fields = ("diode_target",)
27+
28+
def __init__(self, *args, **kwargs):
29+
"""Initialize the form."""
30+
super().__init__(*args, **kwargs)
31+
32+
disallow_diode_target_override = netbox_settings.PLUGINS_CONFIG.get(
33+
"netbox_diode_plugin", {}
34+
).get("disallow_diode_target_override", False)
35+
36+
if disallow_diode_target_override:
37+
self.fields["diode_target"].disabled = True
38+
self.fields["diode_target"].help_text = (
39+
"This field is not allowed to be overridden."
40+
)

netbox_diode_plugin/migrations/0001_initial.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@
1111
from users.models import Token as NetBoxToken
1212

1313

14+
# Read secret from file
15+
def _read_secret(secret_name, default=None):
16+
try:
17+
f = open("/run/secrets/" + secret_name, encoding="utf-8")
18+
except OSError:
19+
return default
20+
else:
21+
with f:
22+
return f.readline().strip()
23+
24+
1425
def _create_user_with_token(apps, username, group, is_superuser: bool = False):
1526
User = apps.get_model(settings.AUTH_USER_MODEL)
1627
"""Create a user with the given username and API key if it does not exist."""
@@ -27,7 +38,8 @@ def _create_user_with_token(apps, username, group, is_superuser: bool = False):
2738
Token = apps.get_model("users", "Token")
2839

2940
if not Token.objects.filter(user=user).exists():
30-
api_key = os.getenv(f"{username}_API_KEY")
41+
key = f"{username}_API_KEY"
42+
api_key = _read_secret(key.lower(), os.getenv(key))
3143
if api_key is None:
3244
api_key = NetBoxToken.generate_key()
3345
Token.objects.create(user=user, key=api_key)

netbox_diode_plugin/migrations/0002_setting.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,19 @@
33
"""Diode Netbox Plugin - Database migrations."""
44

55
import utilities.json
6+
from django.conf import settings as netbox_settings
67
from django.db import migrations, models
78

89

910
def create_settings_entity(apps, schema_editor):
1011
"""Create a Setting entity."""
1112
Setting = apps.get_model("netbox_diode_plugin", "Setting")
12-
Setting.objects.create(diode_target="grpc://localhost:8080/diode")
13+
14+
diode_target = netbox_settings.PLUGINS_CONFIG.get(
15+
"netbox_diode_plugin", {}
16+
).get("diode_target", "grpc://localhost:8080/diode")
17+
18+
Setting.objects.create(diode_target=diode_target)
1319

1420

1521
class Migration(migrations.Migration):
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#!/usr/bin/env python
2+
# Copyright 2024 NetBox Labs Inc
3+
"""Diode NetBox Plugin - Tests."""
4+
from unittest import mock
5+
6+
from django.test import TestCase
7+
8+
from netbox_diode_plugin.forms import SettingsForm
9+
from netbox_diode_plugin.models import Setting
10+
11+
12+
class SettingsFormTestCase(TestCase):
13+
"""Test case for the SettingsForm."""
14+
15+
def setUp(self):
16+
"""Set up the test case."""
17+
self.setting = Setting.objects.create(diode_target="grpc://localhost:8080/diode")
18+
19+
def test_form_initialization_with_override_allowed(self):
20+
"""Test form initialization when override is allowed."""
21+
with mock.patch("netbox_diode_plugin.forms.netbox_settings") as mock_settings:
22+
mock_settings.PLUGINS_CONFIG = {
23+
"netbox_diode_plugin": {
24+
"disallow_diode_target_override": False
25+
}
26+
}
27+
form = SettingsForm(instance=self.setting)
28+
self.assertFalse(form.fields["diode_target"].disabled)
29+
self.assertNotIn("This field is not allowed to be overridden.", form.fields["diode_target"].help_text)
30+
31+
def test_form_initialization_with_override_disallowed(self):
32+
"""Test form initialization when override is disallowed."""
33+
with mock.patch("netbox_diode_plugin.forms.netbox_settings") as mock_settings:
34+
mock_settings.PLUGINS_CONFIG = {
35+
"netbox_diode_plugin": {
36+
"disallow_diode_target_override": True
37+
}
38+
}
39+
form = SettingsForm(instance=self.setting)
40+
self.assertTrue(form.fields["diode_target"].disabled)
41+
self.assertEqual("This field is not allowed to be overridden.", form.fields["diode_target"].help_text)

netbox_diode_plugin/tests/test_views.py

Lines changed: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
from django.contrib.auth import get_user_model
77
from django.contrib.auth.models import AnonymousUser
88
from django.contrib.messages.middleware import MessageMiddleware
9+
from django.contrib.messages.storage.fallback import FallbackStorage
910
from django.contrib.sessions.middleware import SessionMiddleware
1011
from django.core.cache import cache
1112
from django.test import RequestFactory, TestCase
1213
from django.urls import reverse
14+
from rest_framework import status
1315

1416
from netbox_diode_plugin.models import Setting
1517
from netbox_diode_plugin.reconciler.sdk.v1 import ingester_pb2, reconciler_pb2
@@ -35,7 +37,7 @@ def test_returns_200_for_authenticated(self):
3537
self.request.user.is_staff = True
3638

3739
response = self.view.get(self.request)
38-
self.assertEqual(response.status_code, 200)
40+
self.assertEqual(response.status_code, status.HTTP_200_OK)
3941

4042
def test_redirects_to_login_page_for_unauthenticated_user(self):
4143
"""Test that the view returns 200 for an authenticated user."""
@@ -44,7 +46,7 @@ def test_redirects_to_login_page_for_unauthenticated_user(self):
4446

4547
response = IngestionLogsView.as_view()(self.request)
4648

47-
self.assertEqual(response.status_code, 302)
49+
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
4850
self.assertEqual(response.url, f"/netbox/login/?next={self.path}")
4951

5052
def test_ingestion_logs_failed_to_retrieve(self):
@@ -53,7 +55,7 @@ def test_ingestion_logs_failed_to_retrieve(self):
5355
self.request.user.is_staff = True
5456

5557
response = self.view.get(self.request)
56-
self.assertEqual(response.status_code, 200)
58+
self.assertEqual(response.status_code, status.HTTP_200_OK)
5759
self.assertIn(
5860
"UNAVAILABLE: failed to connect to all addresses;", str(response.content)
5961
)
@@ -100,7 +102,7 @@ def test_ingestion_logs_retrieve_logs(self):
100102
response = self.view.get(self.request)
101103
mock_retrieve_ingestion_logs.assert_called()
102104
self.assertEqual(mock_retrieve_ingestion_logs.call_count, 2)
103-
self.assertEqual(response.status_code, 200)
105+
self.assertEqual(response.status_code, status.HTTP_200_OK)
104106
self.assertNotIn("Server Error", str(response.content))
105107

106108
def test_cached_metrics(self):
@@ -153,7 +155,7 @@ def test_cached_metrics(self):
153155
response = self.view.get(self.request)
154156
mock_retrieve_ingestion_logs.assert_called()
155157
self.assertEqual(mock_retrieve_ingestion_logs.call_count, 1)
156-
self.assertEqual(response.status_code, 200)
158+
self.assertEqual(response.status_code, status.HTTP_200_OK)
157159
self.assertNotIn("Server Error", str(response.content))
158160

159161

@@ -173,7 +175,7 @@ def test_returns_200_for_authenticated(self):
173175
self.request.user.is_staff = True
174176

175177
response = self.view.get(self.request)
176-
self.assertEqual(response.status_code, 200)
178+
self.assertEqual(response.status_code, status.HTTP_200_OK)
177179

178180
def test_redirects_to_login_page_for_unauthenticated_user(self):
179181
"""Test that the view returns 200 for an authenticated user."""
@@ -182,7 +184,7 @@ def test_redirects_to_login_page_for_unauthenticated_user(self):
182184

183185
response = SettingsView.as_view()(self.request)
184186

185-
self.assertEqual(response.status_code, 302)
187+
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
186188
self.assertEqual(response.url, f"/netbox/login/?next={self.path}")
187189

188190
def test_settings_created_if_not_found(self):
@@ -194,7 +196,7 @@ def test_settings_created_if_not_found(self):
194196
mock_get.side_effect = Setting.DoesNotExist
195197

196198
response = self.view.get(self.request)
197-
self.assertEqual(response.status_code, 200)
199+
self.assertEqual(response.status_code, status.HTTP_200_OK)
198200
self.assertIn(
199201
"grpc://localhost:8080/diode", str(response.content)
200202
)
@@ -218,7 +220,7 @@ def test_returns_200_for_authenticated(self):
218220
self.view.setup(request)
219221

220222
response = self.view.get(request)
221-
self.assertEqual(response.status_code, 200)
223+
self.assertEqual(response.status_code, status.HTTP_200_OK)
222224

223225
def test_redirects_to_login_page_for_unauthenticated_user(self):
224226
"""Test that the view redirects an authenticated user to login page."""
@@ -227,7 +229,7 @@ def test_redirects_to_login_page_for_unauthenticated_user(self):
227229
self.view.setup(request)
228230

229231
response = self.view.get(request)
230-
self.assertEqual(response.status_code, 302)
232+
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
231233
self.assertEqual(response.url, f"/netbox/login/?next={self.path}")
232234

233235
def test_settings_updated(self):
@@ -241,7 +243,7 @@ def test_settings_updated(self):
241243
self.view.setup(request)
242244

243245
response = self.view.get(request)
244-
self.assertEqual(response.status_code, 200)
246+
self.assertEqual(response.status_code, status.HTTP_200_OK)
245247
self.assertIn("grpc://localhost:8080/diode", str(response.content))
246248

247249
request = self.request_factory.post(self.path)
@@ -258,7 +260,7 @@ def test_settings_updated(self):
258260
request.session.save()
259261

260262
response = self.view.post(request)
261-
self.assertEqual(response.status_code, 302)
263+
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
262264
self.assertEqual(response.url, reverse("plugins:netbox_diode_plugin:settings"))
263265

264266
request = self.request_factory.get(self.path)
@@ -267,7 +269,7 @@ def test_settings_updated(self):
267269
self.view.setup(request)
268270

269271
response = self.view.get(request)
270-
self.assertEqual(response.status_code, 200)
272+
self.assertEqual(response.status_code, status.HTTP_200_OK)
271273
self.assertIn("grpc://localhost:8090/diode", str(response.content))
272274

273275
def test_settings_update_post_redirects_to_login_page_for_unauthenticated_user(
@@ -280,5 +282,45 @@ def test_settings_update_post_redirects_to_login_page_for_unauthenticated_user(
280282
request.POST = {"diode_target": "grpc://localhost:8090/diode"}
281283

282284
response = self.view.post(request)
283-
self.assertEqual(response.status_code, 302)
285+
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
284286
self.assertEqual(response.url, f"/netbox/login/?next={self.path}")
287+
288+
def test_settings_update_disallowed(self):
289+
"""Test that the Diode target cannot be overridden."""
290+
with mock.patch("netbox_diode_plugin.views.netbox_settings") as mock_settings:
291+
mock_settings.PLUGINS_CONFIG = {
292+
"netbox_diode_plugin": {
293+
"disallow_diode_target_override": True
294+
}
295+
}
296+
297+
user = User.objects.create_user("foo", password="pass")
298+
user.is_staff = True
299+
300+
request = self.request_factory.post(self.path)
301+
request.user = user
302+
request.htmx = None
303+
request.POST = {"diode_target": "grpc://localhost:8090/diode"}
304+
305+
middleware = SessionMiddleware(get_response=lambda request: None)
306+
middleware.process_request(request)
307+
request.session.save()
308+
309+
middleware = MessageMiddleware(get_response=lambda request: None)
310+
middleware.process_request(request)
311+
request.session.save()
312+
313+
setattr(request, 'session', 'session')
314+
messages = FallbackStorage(request)
315+
request._messages = messages
316+
317+
self.view.setup(request)
318+
response = self.view.post(request)
319+
320+
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
321+
self.assertEqual(response.url, reverse("plugins:netbox_diode_plugin:settings"))
322+
self.assertEqual(len(request._messages._queued_messages), 1)
323+
self.assertEqual(
324+
str(request._messages._queued_messages[0]),
325+
"The Diode target is not allowed to be overridden.",
326+
)

netbox_diode_plugin/views.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# Copyright 2024 NetBox Labs Inc
33
"""Diode NetBox Plugin - Views."""
44
from django.conf import settings as netbox_settings
5+
from django.contrib import messages
56
from django.contrib.auth import get_user_model
67
from django.core.cache import cache
78
from django.shortcuts import redirect, render
@@ -51,7 +52,10 @@ def get(self, request):
5152
table = IngestionLogsTable(resp.logs)
5253

5354
cached_ingestion_metrics = cache.get(self.INGESTION_METRICS_CACHE_KEY)
54-
if cached_ingestion_metrics is not None and cached_ingestion_metrics["total"] == resp.metrics.total:
55+
if (
56+
cached_ingestion_metrics is not None
57+
and cached_ingestion_metrics["total"] == resp.metrics.total
58+
):
5559
metrics = cached_ingestion_metrics
5660
else:
5761
ingestion_metrics = reconciler_client.retrieve_ingestion_logs(
@@ -150,6 +154,15 @@ def post(self, request, *args, **kwargs):
150154
if not request.user.is_authenticated or not request.user.is_staff:
151155
return redirect(f"{netbox_settings.LOGIN_URL}?next={request.path}")
152156

157+
if netbox_settings.PLUGINS_CONFIG.get("netbox_diode_plugin", {}).get(
158+
"disallow_diode_target_override", False
159+
):
160+
messages.error(
161+
request,
162+
"The Diode target is not allowed to be overridden.",
163+
)
164+
return redirect("plugins:netbox_diode_plugin:settings")
165+
153166
settings = Setting.objects.get()
154167
kwargs["pk"] = settings.pk
155168

0 commit comments

Comments
 (0)