Skip to content

Commit 2c3c80a

Browse files
authored
fix: minimal fixes for NetBox 4.3 initial support (#116)
1 parent ba57526 commit 2c3c80a

File tree

3 files changed

+90
-2
lines changed

3 files changed

+90
-2
lines changed

netbox_diode_plugin/api/compat.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
#!/usr/bin/env python
2+
# Copyright 2025 NetBox Labs Inc
3+
"""Diode NetBox Plugin - API - Compatibility Transformations."""
4+
5+
import logging
6+
import re
7+
from collections import defaultdict
8+
from functools import cache
9+
10+
from django.conf import settings
11+
from packaging import version
12+
from utilities.release import load_release_data
13+
14+
logger = logging.getLogger(__name__)
15+
16+
_MIGRATIONS_BY_OBJECT_TYPE = defaultdict(list)
17+
18+
def apply_entity_migrations(data: dict, object_type: str):
19+
"""
20+
Applies migrations to diode entity data prior to diffing to improve compatibility with current NetBox version.
21+
22+
These represent cases like deprecated fields that have been replaced with new fields, but
23+
are supported for backwards compatibility.
24+
"""
25+
for migration in _MIGRATIONS_BY_OBJECT_TYPE.get(object_type, []):
26+
logger.debug(f"Applying migration {migration.__name__} for {object_type}")
27+
migration(data)
28+
29+
def _register_migration(func, min_version, max_version, object_type):
30+
"""Registers a migration function."""
31+
min_version = version.parse(min_version)
32+
max_version = version.parse(max_version) if max_version else None
33+
current_version = _current_netbox_version()
34+
35+
if current_version < min_version:
36+
logger.debug(f"Skipping migration {func.__name__} for {object_type}: min version {min_version}")
37+
return
38+
if max_version and current_version > max_version:
39+
logger.debug(f"Skipping migration {func.__name__} for {object_type}: max version {max_version}")
40+
return
41+
42+
logger.debug(f"Registering migration {func.__name__} for {object_type}.")
43+
_MIGRATIONS_BY_OBJECT_TYPE[object_type].append(func)
44+
45+
@cache
46+
def _current_netbox_version():
47+
"""Returns the current version of NetBox."""
48+
try:
49+
return version.parse(settings.RELEASE.version)
50+
except Exception:
51+
logger.exception("Failed to determine current version of NetBox.")
52+
return (0, 0, 0)
53+
54+
def diode_migration(min_version: str, max_version: str | None, object_type: str):
55+
"""Decorator to mark a function as a diode migration."""
56+
def decorator(func):
57+
_register_migration(func, min_version, max_version, object_type)
58+
return func
59+
return decorator
60+
61+
@diode_migration(min_version="4.3.0", max_version=None, object_type="ipam.service")
62+
def _migrate_service_parent_object(data: dict):
63+
"""Transforms ipam.service device and virtual_machine references to parent_object."""
64+
device = data.pop("device", None)
65+
if device:
66+
if data.get("parent_object_device") is None:
67+
data["parent_object_device"] = device
68+
# else ignored.
69+
70+
virtual_machine = data.pop("virtual_machine", None)
71+
if virtual_machine:
72+
if data.get("parent_object_virtual_machine") is None:
73+
data["parent_object_virtual_machine"] = virtual_machine
74+
# else ignored.
75+
76+
@diode_migration(min_version="4.3.0", max_version=None, object_type="tenancy.contact")
77+
def _migrate_contact_group(data: dict):
78+
"""Transforms tenancy.contact group references to groups."""
79+
group = data.pop("group", None)
80+
if group:
81+
if data.get("groups") is None:
82+
data["groups"] = [group]
83+
# else ignored.

netbox_diode_plugin/api/plugin_utils.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,8 @@ class RefInfo:
544544
'ipam.service': {
545545
'device': RefInfo(object_type='dcim.device', field_name='device'),
546546
'ipaddresses': RefInfo(object_type='ipam.ipaddress', field_name='ipaddresses', is_many=True),
547+
'parent_object_device': RefInfo(object_type='dcim.device', field_name='parent_object', is_generic=True),
548+
'parent_object_virtual_machine': RefInfo(object_type='virtualization.virtualmachine', field_name='parent_object', is_generic=True),
547549
'tags': RefInfo(object_type='extras.tag', field_name='tags', is_many=True),
548550
'virtual_machine': RefInfo(object_type='virtualization.virtualmachine', field_name='virtual_machine'),
549551
},
@@ -576,6 +578,7 @@ class RefInfo:
576578
},
577579
'tenancy.contact': {
578580
'group': RefInfo(object_type='tenancy.contactgroup', field_name='group'),
581+
'groups': RefInfo(object_type='tenancy.contactgroup', field_name='groups', is_many=True),
579582
'tags': RefInfo(object_type='extras.tag', field_name='tags', is_many=True),
580583
},
581584
'tenancy.contactassignment': {
@@ -948,13 +951,13 @@ def get_json_ref_info(object_type: str|Type[models.Model], json_field_name: str)
948951
'ipam.rir': frozenset(['custom_fields', 'description', 'is_private', 'name', 'slug', 'tags']),
949952
'ipam.role': frozenset(['custom_fields', 'description', 'name', 'slug', 'tags', 'weight']),
950953
'ipam.routetarget': frozenset(['comments', 'custom_fields', 'description', 'name', 'tags', 'tenant']),
951-
'ipam.service': frozenset(['comments', 'custom_fields', 'description', 'device', 'ipaddresses', 'name', 'ports', 'protocol', 'tags', 'virtual_machine']),
954+
'ipam.service': frozenset(['comments', 'custom_fields', 'description', 'device', 'ipaddresses', 'name', 'parent_object_id', 'parent_object_type', 'ports', 'protocol', 'tags', 'virtual_machine']),
952955
'ipam.vlan': frozenset(['comments', 'custom_fields', 'description', 'group', 'name', 'qinq_role', 'qinq_svlan', 'role', 'site', 'status', 'tags', 'tenant', 'vid']),
953956
'ipam.vlangroup': frozenset(['custom_fields', 'description', 'name', 'scope_id', 'scope_type', 'slug', 'tags', 'vid_ranges']),
954957
'ipam.vlantranslationpolicy': frozenset(['description', 'name']),
955958
'ipam.vlantranslationrule': frozenset(['description', 'local_vid', 'policy', 'remote_vid']),
956959
'ipam.vrf': frozenset(['comments', 'custom_fields', 'description', 'enforce_unique', 'export_targets', 'import_targets', 'name', 'rd', 'tags', 'tenant']),
957-
'tenancy.contact': frozenset(['address', 'comments', 'custom_fields', 'description', 'email', 'group', 'link', 'name', 'phone', 'tags', 'title']),
960+
'tenancy.contact': frozenset(['address', 'comments', 'custom_fields', 'description', 'email', 'group', 'groups', 'link', 'name', 'phone', 'tags', 'title']),
958961
'tenancy.contactassignment': frozenset(['contact', 'custom_fields', 'object_id', 'object_type', 'priority', 'role', 'tags']),
959962
'tenancy.contactgroup': frozenset(['custom_fields', 'description', 'name', 'parent', 'slug', 'tags']),
960963
'tenancy.contactrole': frozenset(['custom_fields', 'description', 'name', 'slug', 'tags']),

netbox_diode_plugin/api/transformer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from rest_framework import serializers
1818

1919
from .common import NON_FIELD_ERRORS, AutoSlug, ChangeSetException, UnresolvedReference, harmonize_formats, sort_ints_first
20+
from .compat import apply_entity_migrations
2021
from .matcher import find_existing_object, fingerprints
2122
from .plugin_utils import (
2223
CUSTOM_FIELD_OBJECT_REFERENCE_TYPE,
@@ -125,6 +126,7 @@ def _transform_proto_json_1(proto_json: dict, object_type: str, context=None) ->
125126
# handle camelCase protoJSON if provided...
126127
proto_json = _ensure_snake_case(proto_json, object_type)
127128
apply_format_transformations(proto_json, object_type)
129+
apply_entity_migrations(proto_json, object_type)
128130

129131
# context pushed down from parent nodes
130132
if context is not None:

0 commit comments

Comments
 (0)