Skip to content

Commit a90ffb2

Browse files
authored
🚚 release (#165)
2 parents 521969b + 8a42751 commit a90ffb2

10 files changed

Lines changed: 443 additions & 11 deletions

File tree

‎docker/common/requirements-diode-netbox-plugin.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@ certifi==2024.7.4
33
coverage==7.6.0
44
grpcio==1.62.1
55
protobuf==5.29.6
6-
pytest==8.0.2
6+
pytest==9.0.3
77
netboxlabs-netbox-branching==0.8.3
88
granian[reload]

‎docker/v4.4.x/requirements-diode-netbox-plugin.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@ certifi==2024.7.4
33
coverage==7.6.0
44
grpcio==1.62.1
55
protobuf==5.29.6
6-
pytest==8.0.2
6+
pytest==9.0.3
77
netboxlabs-netbox-branching==0.7.1

‎docker/v4.5.x/requirements-diode-netbox-plugin.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@ certifi==2024.7.4
33
coverage==7.6.0
44
grpcio==1.62.1
55
protobuf==5.29.6
6-
pytest==8.0.2
6+
pytest==9.0.3
77
netboxlabs-netbox-branching==0.8.3
88
granian[reload]

‎netbox_diode_plugin/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ class NetBoxDiodePluginConfig(PluginConfig):
4343
# TTL in seconds for caching find_existing_object results in Redis.
4444
# Set to 0 to disable caching.
4545
"find_obj_cache_ttl": 5,
46+
47+
# Override the displayed Diode target URL without affecting internal
48+
# communication (e.g. to show the external ingress address).
49+
"diode_target_display": None,
4650
}
4751

4852

‎netbox_diode_plugin/api/matcher.py‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,32 @@ def _get_find_obj_cache_ttl() -> int:
3232
return get_plugin_config("netbox_diode_plugin", "find_obj_cache_ttl")
3333

3434

35+
def _get_active_branch_schema() -> str | None:
36+
try:
37+
from netbox_branching.contextvars import active_branch
38+
except ImportError:
39+
return None
40+
branch = active_branch.get()
41+
if branch is not None:
42+
return branch.schema_id
43+
return None
44+
45+
46+
def _find_obj_rev_key(object_type: str, object_id: int) -> str:
47+
branch_schema = _get_active_branch_schema()
48+
if branch_schema:
49+
return f"diode:fobj:rev:{branch_schema}:{object_type}:{object_id}"
50+
return f"diode:fobj:rev:{object_type}:{object_id}"
51+
52+
3553
def invalidate_find_obj_entry(object_type: str, object_id: int):
3654
"""
3755
Delete a cached find_existing_object result by PK.
3856
3957
Uses a reverse-index (PK → cache key) to find and delete the
4058
lookup cache entry. Call this after updating an existing object.
4159
"""
42-
rev_key = f"diode:fobj:rev:{object_type}:{object_id}"
60+
rev_key = _find_obj_rev_key(object_type, object_id)
4361
lookup_key = django_cache.get(rev_key)
4462
if lookup_key:
4563
django_cache.delete(lookup_key)
@@ -921,7 +939,11 @@ def _find_obj_cache_key(data: dict, object_type: str) -> str | None:
921939
if not items:
922940
return None
923941

924-
raw = f"{object_type}:{items}"
942+
branch_schema = _get_active_branch_schema()
943+
if branch_schema:
944+
raw = f"{branch_schema}:{object_type}:{items}"
945+
else:
946+
raw = f"{object_type}:{items}"
925947
key_hash = hashlib.sha256(raw.encode()).hexdigest()[:20]
926948
return f"diode:fobj:{key_hash}"
927949

@@ -955,8 +977,7 @@ def find_existing_object(data: dict, object_type: str): # noqa: C901
955977
# Object deleted since cached — clean up and fall through
956978
cache_hit = False
957979
django_cache.delete(cache_key)
958-
rev_key = f"diode:fobj:rev:{object_type}:{cached_id}"
959-
django_cache.delete(rev_key)
980+
django_cache.delete(_find_obj_rev_key(object_type, cached_id))
960981

961982
if not cache_hit:
962983
for matcher in get_model_matchers(model_class):
@@ -973,8 +994,7 @@ def find_existing_object(data: dict, object_type: str): # noqa: C901
973994

974995
if cache_key and result is not None:
975996
django_cache.set(cache_key, result.id, cache_ttl)
976-
rev_key = f"diode:fobj:rev:{object_type}:{result.id}"
977-
django_cache.set(rev_key, cache_key, cache_ttl)
997+
django_cache.set(_find_obj_rev_key(object_type, result.id), cache_key, cache_ttl)
978998

979999
if ctx:
9801000
ctx.record_timing("find_obj", (time.monotonic() - start) * 1000)

‎netbox_diode_plugin/tests/v4.4.x/tests/test_matcher_cache.py‎

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from netbox_diode_plugin.api.common import UnresolvedReference
1212
from netbox_diode_plugin.api.matcher import (
1313
_find_obj_cache_key,
14+
_find_obj_rev_key,
1415
find_existing_object,
1516
invalidate_find_obj_entry,
1617
)
@@ -292,3 +293,154 @@ def test_invalidate_does_not_affect_other_entries(self, _mock_ttl):
292293
# Main should be gone
293294
cache_key_main = _find_obj_cache_key(data_main, "dcim.manufacturer")
294295
self.assertIsNone(django_cache.get(cache_key_main))
296+
297+
298+
BRANCH_SCHEMA_MOCK = "netbox_diode_plugin.api.matcher._get_active_branch_schema"
299+
300+
301+
class BranchAwareCacheKeyTestCase(TestCase):
302+
"""Tests that cache keys are isolated per branch."""
303+
304+
def setUp(self):
305+
"""Clear cache before each test."""
306+
django_cache.clear()
307+
308+
def tearDown(self):
309+
"""Clear cache after each test."""
310+
django_cache.clear()
311+
312+
def test_different_branch_different_cache_key(self):
313+
"""Same data under different branches produces different cache keys."""
314+
data = {"name": "Cisco"}
315+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
316+
key_a = _find_obj_cache_key(data, "dcim.manufacturer")
317+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_b"):
318+
key_b = _find_obj_cache_key(data, "dcim.manufacturer")
319+
self.assertNotEqual(key_a, key_b)
320+
321+
def test_same_branch_same_cache_key(self):
322+
"""Same data under the same branch produces identical cache keys."""
323+
data = {"name": "Cisco"}
324+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
325+
key1 = _find_obj_cache_key(data, "dcim.manufacturer")
326+
key2 = _find_obj_cache_key(data, "dcim.manufacturer")
327+
self.assertEqual(key1, key2)
328+
329+
def test_no_branch_unchanged_from_legacy_format(self):
330+
"""Without active branch, cache key matches the original format."""
331+
data = {"name": "Cisco"}
332+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value=None):
333+
key = _find_obj_cache_key(data, "dcim.manufacturer")
334+
self.assertTrue(key.startswith("diode:fobj:"))
335+
336+
def test_branch_vs_no_branch_different_keys(self):
337+
"""A branched key differs from a non-branched key for the same data."""
338+
data = {"name": "Cisco"}
339+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value=None):
340+
key_main = _find_obj_cache_key(data, "dcim.manufacturer")
341+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
342+
key_branch = _find_obj_cache_key(data, "dcim.manufacturer")
343+
self.assertNotEqual(key_main, key_branch)
344+
345+
def test_different_branch_different_rev_key(self):
346+
"""Reverse-index keys are isolated per branch."""
347+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
348+
rev_a = _find_obj_rev_key("dcim.manufacturer", 100)
349+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_b"):
350+
rev_b = _find_obj_rev_key("dcim.manufacturer", 100)
351+
self.assertNotEqual(rev_a, rev_b)
352+
self.assertIn("branch_a", rev_a)
353+
self.assertIn("branch_b", rev_b)
354+
355+
def test_no_branch_rev_key_unchanged(self):
356+
"""Without active branch, rev key matches the original format."""
357+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value=None):
358+
rev = _find_obj_rev_key("dcim.manufacturer", 100)
359+
self.assertEqual(rev, "diode:fobj:rev:dcim.manufacturer:100")
360+
361+
362+
class BranchAwareFindExistingObjectTestCase(TestCase):
363+
"""Tests that find_existing_object cache does not cross branches."""
364+
365+
def setUp(self):
366+
"""Set up test fixtures."""
367+
self.manufacturer = Manufacturer.objects.create(
368+
name="BranchTestMfr",
369+
slug="branch-test-mfr",
370+
)
371+
django_cache.clear()
372+
373+
def tearDown(self):
374+
"""Clean up cache after each test."""
375+
django_cache.clear()
376+
377+
@mock.patch(
378+
"netbox_diode_plugin.api.matcher._get_find_obj_cache_ttl",
379+
return_value=5,
380+
)
381+
def test_cache_does_not_cross_branches(self, _mock_ttl):
382+
"""Cache populated under branch A is a miss under branch B."""
383+
data = {"name": "BranchTestMfr", "_object_type": "dcim.manufacturer"}
384+
385+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
386+
result_a = find_existing_object(data, "dcim.manufacturer")
387+
self.assertEqual(result_a.id, self.manufacturer.id)
388+
cache_key_a = _find_obj_cache_key(data, "dcim.manufacturer")
389+
self.assertIsNotNone(django_cache.get(cache_key_a))
390+
391+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_b"):
392+
cache_key_b = _find_obj_cache_key(data, "dcim.manufacturer")
393+
self.assertIsNone(django_cache.get(cache_key_b))
394+
395+
@mock.patch(
396+
"netbox_diode_plugin.api.matcher._get_find_obj_cache_ttl",
397+
return_value=5,
398+
)
399+
def test_invalidate_does_not_cross_branches(self, _mock_ttl):
400+
"""Invalidation under branch B does not evict branch A's cache."""
401+
data = {"name": "BranchTestMfr", "_object_type": "dcim.manufacturer"}
402+
403+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
404+
find_existing_object(data, "dcim.manufacturer")
405+
cache_key_a = _find_obj_cache_key(data, "dcim.manufacturer")
406+
self.assertIsNotNone(django_cache.get(cache_key_a))
407+
408+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_b"):
409+
invalidate_find_obj_entry("dcim.manufacturer", self.manufacturer.id)
410+
411+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
412+
self.assertIsNotNone(django_cache.get(cache_key_a))
413+
414+
@mock.patch(
415+
"netbox_diode_plugin.api.matcher._get_find_obj_cache_ttl",
416+
return_value=5,
417+
)
418+
def test_invalidate_within_same_branch(self, _mock_ttl):
419+
"""Invalidation under the same branch correctly evicts cache."""
420+
data = {"name": "BranchTestMfr", "_object_type": "dcim.manufacturer"}
421+
422+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
423+
find_existing_object(data, "dcim.manufacturer")
424+
cache_key_a = _find_obj_cache_key(data, "dcim.manufacturer")
425+
self.assertIsNotNone(django_cache.get(cache_key_a))
426+
invalidate_find_obj_entry("dcim.manufacturer", self.manufacturer.id)
427+
self.assertIsNone(django_cache.get(cache_key_a))
428+
429+
@mock.patch(
430+
"netbox_diode_plugin.api.matcher._get_find_obj_cache_ttl",
431+
return_value=5,
432+
)
433+
def test_branch_and_main_caches_independent(self, _mock_ttl):
434+
"""Cache entries for main (no branch) and a branch are independent."""
435+
data = {"name": "BranchTestMfr", "_object_type": "dcim.manufacturer"}
436+
437+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value=None):
438+
find_existing_object(data, "dcim.manufacturer")
439+
cache_key_main = _find_obj_cache_key(data, "dcim.manufacturer")
440+
441+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value="branch_a"):
442+
cache_key_branch = _find_obj_cache_key(data, "dcim.manufacturer")
443+
self.assertIsNone(django_cache.get(cache_key_branch))
444+
445+
with mock.patch(BRANCH_SCHEMA_MOCK, return_value=None):
446+
self.assertIsNotNone(django_cache.get(cache_key_main))

‎netbox_diode_plugin/tests/v4.4.x/tests/test_views.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,53 @@ def test_settings_created_if_not_found(self):
7676
self.assertIn("grpc://localhost:8080/diode", str(response.content))
7777

7878

79+
def test_settings_display_with_diode_target_display(self):
80+
"""Test that diode_target_display overrides the displayed target."""
81+
self.request.user = User.objects.create_user("foo", password="pass")
82+
self.add_permissions(self.request.user, "netbox_diode_plugin.view_setting")
83+
84+
def mock_get_plugin_config(plugin, key):
85+
if key == "diode_target_display":
86+
return "grpcs://external.example.com/diode"
87+
if key == "diode_target_override":
88+
return None
89+
if key == "diode_target":
90+
return "grpc://localhost:8080/diode"
91+
return None
92+
93+
with mock.patch(
94+
"netbox_diode_plugin.views.get_plugin_config",
95+
side_effect=mock_get_plugin_config,
96+
):
97+
response = self.view.get(self.request)
98+
self.assertEqual(response.status_code, status.HTTP_200_OK)
99+
self.assertIn("grpcs://external.example.com/diode", str(response.content))
100+
self.assertNotIn("grpc://localhost:8080/diode", str(response.content))
101+
102+
def test_diode_target_display_takes_precedence_over_override(self):
103+
"""Test that diode_target_display takes precedence over diode_target_override for display."""
104+
self.request.user = User.objects.create_user("foo", password="pass")
105+
self.add_permissions(self.request.user, "netbox_diode_plugin.view_setting")
106+
107+
def mock_get_plugin_config(plugin, key):
108+
if key == "diode_target_display":
109+
return "grpcs://external.example.com/diode"
110+
if key == "diode_target_override":
111+
return "grpc://internal-override:8080/diode"
112+
if key == "diode_target":
113+
return "grpc://localhost:8080/diode"
114+
return None
115+
116+
with mock.patch(
117+
"netbox_diode_plugin.views.get_plugin_config",
118+
side_effect=mock_get_plugin_config,
119+
):
120+
response = self.view.get(self.request)
121+
self.assertEqual(response.status_code, status.HTTP_200_OK)
122+
self.assertIn("grpcs://external.example.com/diode", str(response.content))
123+
self.assertNotIn("grpc://internal-override:8080/diode", str(response.content))
124+
125+
79126
class SettingsEditViewTestCase(TestCase):
80127
"""Test case for the SettingsEditView."""
81128

0 commit comments

Comments
 (0)