Skip to content

Commit 9b01b3e

Browse files
authored
fix: special cases for ip network defaulting (#81)
* fix: special handling for ip address network defaulting * fix: match ips ignoring mask value, use specific matchers
1 parent 53158b2 commit 9b01b3e

File tree

5 files changed

+344
-11
lines changed

5 files changed

+344
-11
lines changed

netbox_diode_plugin/api/common.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ class AutoSlug:
238238

239239

240240
def error_from_validation_error(e, object_name):
241-
"""Convert a drf ValidationError to a ChangeSetException."""
241+
"""Convert a from rest_framework.exceptions.ValidationError to a ChangeSetException."""
242242
errors = {}
243243
if e.detail:
244244
if isinstance(e.detail, dict):

netbox_diode_plugin/api/differ.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from django.core.exceptions import ValidationError
1111
from utilities.data import shallow_compare_dict
1212
from django.db.backends.postgresql.psycopg_any import NumericRange
13+
import netaddr
1314

1415
from .common import Change, ChangeSet, ChangeSetException, ChangeSetResult, ChangeType, error_from_validation_error
1516
from .plugin_utils import get_primary_value, legal_fields
@@ -84,6 +85,10 @@ def prechange_data_from_instance(instance) -> dict: # noqa: C901
8485

8586

8687
def _harmonize_formats(prechange_data):
88+
if prechange_data is None:
89+
return None
90+
if isinstance(prechange_data, (str, int, float, bool)):
91+
return prechange_data
8792
if isinstance(prechange_data, dict):
8893
return {k: _harmonize_formats(v) for k, v in prechange_data.items()}
8994
if isinstance(prechange_data, (list, tuple)):
@@ -94,8 +99,11 @@ def _harmonize_formats(prechange_data):
9499
return prechange_data.strftime("%Y-%m-%d")
95100
if isinstance(prechange_data, NumericRange):
96101
return (prechange_data.lower, prechange_data.upper-1)
102+
if isinstance(prechange_data, netaddr.IPNetwork):
103+
return str(prechange_data)
97104

98-
return prechange_data
105+
logger.warning(f"Unknown type in prechange_data: {type(prechange_data)}")
106+
return str(prechange_data)
99107

100108
def clean_diff_data(data: dict, exclude_empty_values: bool = True) -> dict:
101109
"""Clean diff data by removing null values."""

netbox_diode_plugin/api/matcher.py

+129-8
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from django.db.models.fields import SlugField
1818
from django.db.models.lookups import Exact
1919
from django.db.models.query_utils import Q
20+
import netaddr
2021
from extras.models.customfields import CustomField
2122

2223
from .common import AutoSlug, UnresolvedReference
@@ -46,17 +47,17 @@
4647
),
4748
],
4849
"ipam.ipaddress": lambda: [
49-
ObjectMatchCriteria(
50-
fields=("address", ),
51-
name="logical_ip_address_global_no_vrf",
50+
GlobalIPNetworkIPMatcher(
51+
ip_field="address",
52+
vrf_field="vrf",
5253
model_class=get_object_type_model("ipam.ipaddress"),
53-
condition=Q(vrf__isnull=True),
54+
name="logical_ip_address_global_no_vrf",
5455
),
55-
ObjectMatchCriteria(
56-
fields=("address", "assigned_object_type", "assigned_object_id"),
57-
name="logical_ip_address_within_vrf",
56+
VRFIPNetworkIPMatcher(
57+
ip_field="address",
58+
vrf_field="vrf",
5859
model_class=get_object_type_model("ipam.ipaddress"),
59-
condition=Q(vrf__isnull=False)
60+
name="logical_ip_address_within_vrf",
6061
),
6162
],
6263
"ipam.prefix": lambda: [
@@ -271,6 +272,8 @@ def _prepare_data(self, data: dict) -> dict:
271272
continue
272273
return prepared
273274

275+
276+
274277
@dataclass
275278
class CustomFieldMatcher:
276279
"""A matcher for a unique custom field."""
@@ -305,6 +308,124 @@ def has_required_fields(self, data: dict) -> bool:
305308
"""Returns True if the data given contains a value for all fields referenced by the constraint."""
306309
return self.custom_field in data.get("custom_fields", {})
307310

311+
312+
@dataclass
313+
class GlobalIPNetworkIPMatcher:
314+
"""A matcher that ignores the mask."""
315+
316+
ip_field: str
317+
vrf_field: str
318+
model_class: Type[models.Model]
319+
name: str
320+
321+
def _check_condition(self, data: dict) -> bool:
322+
"""Check the condition for the custom field."""
323+
return data.get(self.vrf_field, None) is None
324+
325+
def fingerprint(self, data: dict) -> str|None:
326+
"""Fingerprint the custom field value."""
327+
if not self.has_required_fields(data):
328+
return None
329+
330+
if not self._check_condition(data):
331+
return None
332+
333+
value = self.ip_value(data)
334+
if value is None:
335+
return None
336+
337+
return hash((self.model_class.__name__, self.name, value))
338+
339+
def has_required_fields(self, data: dict) -> bool:
340+
"""Returns True if the data given contains a value for all fields referenced by the constraint."""
341+
return self.ip_field in data
342+
343+
def ip_value(self, data: dict) -> str|None:
344+
"""Get the IP value from the data."""
345+
value = data.get(self.ip_field)
346+
if value is None:
347+
return None
348+
return _ip_only(value)
349+
350+
def build_queryset(self, data: dict) -> models.QuerySet:
351+
"""Build a queryset for the custom field."""
352+
if not self.has_required_fields(data):
353+
return None
354+
355+
if not self._check_condition(data):
356+
return None
357+
358+
value = self.ip_value(data)
359+
if value is None:
360+
return None
361+
362+
return self.model_class.objects.filter(**{f'{self.ip_field}__net_host': value, f'{self.vrf_field}__isnull': True})
363+
364+
@dataclass
365+
class VRFIPNetworkIPMatcher:
366+
"""Matches ip in a vrf, ignores mask."""
367+
368+
ip_field: str
369+
vrf_field: str
370+
model_class: Type[models.Model]
371+
name: str
372+
373+
def _check_condition(self, data: dict) -> bool:
374+
"""Check the condition for the custom field."""
375+
return data.get('vrf_id', None) is not None
376+
377+
def fingerprint(self, data: dict) -> str|None:
378+
"""Fingerprint the custom field value."""
379+
if not self.has_required_fields(data):
380+
return None
381+
382+
if not self._check_condition(data):
383+
return None
384+
385+
value = self.ip_value(data)
386+
if value is None:
387+
return None
388+
389+
vrf_id = data[self.vrf_field]
390+
391+
return hash((self.model_class.__name__, self.name, value, vrf_id))
392+
393+
def has_required_fields(self, data: dict) -> bool:
394+
"""Returns True if the data given contains a value for all fields referenced by the constraint."""
395+
return self.ip_field in data and self.vrf_field in data
396+
397+
def ip_value(self, data: dict) -> str|None:
398+
"""Get the IP value from the data."""
399+
value = data.get(self.ip_field)
400+
if value is None:
401+
return None
402+
return _ip_only(value)
403+
404+
def build_queryset(self, data: dict) -> models.QuerySet:
405+
"""Build a queryset for the custom field."""
406+
if not self.has_required_fields(data):
407+
return None
408+
409+
if not self._check_condition(data):
410+
return None
411+
412+
value = self.ip_value(data)
413+
if value is None:
414+
return None
415+
416+
vrf_id = data[self.vrf_field]
417+
return self.model_class.objects.filter(**{f'{self.ip_field}__net_host': value, f'{self.vrf_field}': vrf_id})
418+
419+
420+
def _ip_only(value: str) -> str|None:
421+
try:
422+
ip = netaddr.IPNetwork(value)
423+
value = ip.ip
424+
except netaddr.core.AddrFormatError:
425+
return None
426+
427+
return value
428+
308429
@dataclass
309430
class AutoSlugMatcher:
310431
"""A special matcher that tries to match on auto generated slugs."""

netbox_diode_plugin/api/plugin_utils.py

+20-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Diode plugin helpers."""
22

33
# Generated code. DO NOT EDIT.
4-
# Timestamp: 2025-04-13 13:20:10Z
4+
# Timestamp: 2025-04-13 16:50:25Z
55

66
from dataclasses import dataclass
77
import datetime
@@ -13,6 +13,8 @@
1313
from core.models import ObjectType as NetBoxType
1414
from django.contrib.contenttypes.models import ContentType
1515
from django.db import models
16+
import netaddr
17+
from rest_framework.exceptions import ValidationError
1618

1719
logger = logging.getLogger(__name__)
1820

@@ -1014,6 +1016,12 @@ def transform_float_to_decimal(value: float) -> decimal.Decimal:
10141016
def int_from_int64string(value: str) -> int:
10151017
return int(value)
10161018

1019+
def ip_network_defaulting(value: str) -> str:
1020+
try:
1021+
return str(netaddr.IPNetwork(value))
1022+
except netaddr.AddrFormatError:
1023+
raise ValueError(f'Invalid IP network value: {value}')
1024+
10171025
def collect_integer_pairs(value: list[int]) -> list[tuple[int, int]]:
10181026
if len(value) % 2 != 0:
10191027
raise ValueError('Array must have an even number of elements')
@@ -1114,6 +1122,7 @@ def wrapper(value):
11141122
},
11151123
'ipam.aggregate': {
11161124
'date_added': transform_timestamp_to_date_only,
1125+
'prefix': ip_network_defaulting,
11171126
},
11181127
'ipam.asn': {
11191128
'asn': int_from_int64string,
@@ -1128,6 +1137,16 @@ def wrapper(value):
11281137
'ipam.fhrpgroupassignment': {
11291138
'priority': int_from_int64string,
11301139
},
1140+
'ipam.ipaddress': {
1141+
'address': ip_network_defaulting,
1142+
},
1143+
'ipam.iprange': {
1144+
'end_address': ip_network_defaulting,
1145+
'start_address': ip_network_defaulting,
1146+
},
1147+
'ipam.prefix': {
1148+
'prefix': ip_network_defaulting,
1149+
},
11311150
'ipam.role': {
11321151
'weight': int_from_int64string,
11331152
},

0 commit comments

Comments
 (0)