netbox-interface-name-rules 1.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.
- netbox_interface_name_rules-1.0.0/LICENSE +1 -0
- netbox_interface_name_rules-1.0.0/PKG-INFO +60 -0
- netbox_interface_name_rules-1.0.0/README.md +50 -0
- netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/__init__.py +23 -0
- netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/api/__init__.py +2 -0
- netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/api/serializers.py +20 -0
- netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/api/urls.py +12 -0
- netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/api/views.py +12 -0
- netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/engine.py +241 -0
- netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/filters.py +11 -0
- netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/forms.py +42 -0
- netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/migrations/0001_initial.py +97 -0
- netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/migrations/__init__.py +0 -0
- netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/models.py +83 -0
- netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/navigation.py +31 -0
- netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/signals.py +76 -0
- netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/tables.py +44 -0
- netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/tests/__init__.py +2 -0
- netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/urls.py +23 -0
- netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/utils.py +19 -0
- netbox_interface_name_rules-1.0.0/netbox_interface_name_rules/views.py +58 -0
- netbox_interface_name_rules-1.0.0/netbox_interface_name_rules.egg-info/PKG-INFO +60 -0
- netbox_interface_name_rules-1.0.0/netbox_interface_name_rules.egg-info/SOURCES.txt +25 -0
- netbox_interface_name_rules-1.0.0/netbox_interface_name_rules.egg-info/dependency_links.txt +1 -0
- netbox_interface_name_rules-1.0.0/netbox_interface_name_rules.egg-info/top_level.txt +1 -0
- netbox_interface_name_rules-1.0.0/pyproject.toml +55 -0
- netbox_interface_name_rules-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Apache License 2.0
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: netbox-interface-name-rules
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: NetBox plugin for automatic interface renaming when modules are installed
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.12.0
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Dynamic: license-file
|
|
10
|
+
|
|
11
|
+
# NetBox Interface Name Rules Plugin
|
|
12
|
+
|
|
13
|
+
Automatic interface renaming when modules are installed into NetBox device bays.
|
|
14
|
+
|
|
15
|
+
## What it does
|
|
16
|
+
|
|
17
|
+
When a module (transceiver, line card, converter) is installed into a module bay,
|
|
18
|
+
NetBox creates interfaces using position-based naming from the module type template.
|
|
19
|
+
This often produces incorrect names — e.g., `Interface 1` instead of `et-0/0/4`.
|
|
20
|
+
|
|
21
|
+
This plugin hooks into Django's `post_save` signal on the `Module` model to
|
|
22
|
+
automatically apply renaming rules based on configurable templates.
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
- **Signal-driven** — rules fire automatically on module install, no manual step needed
|
|
27
|
+
- **Template variables** — `{slot}`, `{bay_position}`, `{bay_position_num}`, `{base}`, `{channel}`, etc.
|
|
28
|
+
- **Arithmetic expressions** — `{8 + ({parent_bay_position} - 1) * 2 + {sfp_slot}}`
|
|
29
|
+
- **Breakout support** — create multiple channel interfaces from a single port (e.g., QSFP+ 4x10G)
|
|
30
|
+
- **Scoping** — rules can be scoped to specific device types, parent module types, or be universal
|
|
31
|
+
- **Bulk import/export** — YAML-based rule management via the UI or API
|
|
32
|
+
|
|
33
|
+
## Supported scenarios
|
|
34
|
+
|
|
35
|
+
| Scenario | Example |
|
|
36
|
+
|----------|---------|
|
|
37
|
+
| Converter offset | GLC-T in CVR-X2-SFP → `GigabitEthernet3/10` |
|
|
38
|
+
| Breakout channels | QSFP-4X10G-LR → `et-0/0/4:0` through `et-0/0/4:3` |
|
|
39
|
+
| Platform naming | QSFP-100G-LR4 on ACX7024 → `et-0/0/{bay_position}` |
|
|
40
|
+
| UfiSpace breakout | QSFP-100G on S9610 → `swp{bay_position_num}s{channel}` |
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install netbox-interface-name-rules
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Add to `configuration.py`:
|
|
49
|
+
```python
|
|
50
|
+
PLUGINS = ['netbox_interface_name_rules']
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Compatibility
|
|
54
|
+
|
|
55
|
+
- NetBox ≥ 4.2.0
|
|
56
|
+
- Python ≥ 3.12
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
Apache 2.0
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# NetBox Interface Name Rules Plugin
|
|
2
|
+
|
|
3
|
+
Automatic interface renaming when modules are installed into NetBox device bays.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
When a module (transceiver, line card, converter) is installed into a module bay,
|
|
8
|
+
NetBox creates interfaces using position-based naming from the module type template.
|
|
9
|
+
This often produces incorrect names — e.g., `Interface 1` instead of `et-0/0/4`.
|
|
10
|
+
|
|
11
|
+
This plugin hooks into Django's `post_save` signal on the `Module` model to
|
|
12
|
+
automatically apply renaming rules based on configurable templates.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- **Signal-driven** — rules fire automatically on module install, no manual step needed
|
|
17
|
+
- **Template variables** — `{slot}`, `{bay_position}`, `{bay_position_num}`, `{base}`, `{channel}`, etc.
|
|
18
|
+
- **Arithmetic expressions** — `{8 + ({parent_bay_position} - 1) * 2 + {sfp_slot}}`
|
|
19
|
+
- **Breakout support** — create multiple channel interfaces from a single port (e.g., QSFP+ 4x10G)
|
|
20
|
+
- **Scoping** — rules can be scoped to specific device types, parent module types, or be universal
|
|
21
|
+
- **Bulk import/export** — YAML-based rule management via the UI or API
|
|
22
|
+
|
|
23
|
+
## Supported scenarios
|
|
24
|
+
|
|
25
|
+
| Scenario | Example |
|
|
26
|
+
|----------|---------|
|
|
27
|
+
| Converter offset | GLC-T in CVR-X2-SFP → `GigabitEthernet3/10` |
|
|
28
|
+
| Breakout channels | QSFP-4X10G-LR → `et-0/0/4:0` through `et-0/0/4:3` |
|
|
29
|
+
| Platform naming | QSFP-100G-LR4 on ACX7024 → `et-0/0/{bay_position}` |
|
|
30
|
+
| UfiSpace breakout | QSFP-100G on S9610 → `swp{bay_position_num}s{channel}` |
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install netbox-interface-name-rules
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Add to `configuration.py`:
|
|
39
|
+
```python
|
|
40
|
+
PLUGINS = ['netbox_interface_name_rules']
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Compatibility
|
|
44
|
+
|
|
45
|
+
- NetBox ≥ 4.2.0
|
|
46
|
+
- Python ≥ 3.12
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
Apache 2.0
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
|
|
3
|
+
from netbox.plugins import PluginConfig
|
|
4
|
+
|
|
5
|
+
__version__ = "1.0.0"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InterfaceNameRulesConfig(PluginConfig):
|
|
9
|
+
name = "netbox_interface_name_rules"
|
|
10
|
+
verbose_name = "Interface Name Rules"
|
|
11
|
+
description = "Automatic interface renaming when modules are installed into bays."
|
|
12
|
+
version = __version__
|
|
13
|
+
base_url = "interface-name-rules"
|
|
14
|
+
min_version = "4.2.0"
|
|
15
|
+
required_settings = []
|
|
16
|
+
default_settings = {}
|
|
17
|
+
|
|
18
|
+
def ready(self):
|
|
19
|
+
super().ready()
|
|
20
|
+
from . import signals # noqa: F401 — registers post_save handler
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
config = InterfaceNameRulesConfig
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
|
|
3
|
+
from netbox.api.serializers import NetBoxModelSerializer
|
|
4
|
+
|
|
5
|
+
from netbox_interface_name_rules.models import InterfaceNameRule
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InterfaceNameRuleSerializer(NetBoxModelSerializer):
|
|
9
|
+
class Meta:
|
|
10
|
+
model = InterfaceNameRule
|
|
11
|
+
fields = [
|
|
12
|
+
"id",
|
|
13
|
+
"module_type",
|
|
14
|
+
"parent_module_type",
|
|
15
|
+
"device_type",
|
|
16
|
+
"name_template",
|
|
17
|
+
"channel_count",
|
|
18
|
+
"channel_start",
|
|
19
|
+
"description",
|
|
20
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
|
|
3
|
+
from netbox.api.routers import NetBoxRouter
|
|
4
|
+
|
|
5
|
+
from . import views
|
|
6
|
+
|
|
7
|
+
app_name = "netbox_interface_name_rules"
|
|
8
|
+
|
|
9
|
+
router = NetBoxRouter()
|
|
10
|
+
router.register("rules", views.InterfaceNameRuleViewSet)
|
|
11
|
+
|
|
12
|
+
urlpatterns = router.urls
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
|
|
3
|
+
from netbox.api.viewsets import NetBoxModelViewSet
|
|
4
|
+
|
|
5
|
+
from netbox_interface_name_rules.models import InterfaceNameRule
|
|
6
|
+
|
|
7
|
+
from .serializers import InterfaceNameRuleSerializer
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InterfaceNameRuleViewSet(NetBoxModelViewSet):
|
|
11
|
+
queryset = InterfaceNameRule.objects.all()
|
|
12
|
+
serializer_class = InterfaceNameRuleSerializer
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
|
|
3
|
+
"""Core renaming engine — rule lookup and interface rename logic.
|
|
4
|
+
|
|
5
|
+
This module is imported lazily by signals.py so that model imports happen
|
|
6
|
+
after Django is fully initialised.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def apply_interface_name_rules(module, module_bay):
|
|
14
|
+
"""Apply InterfaceNameRule rename after module installation.
|
|
15
|
+
|
|
16
|
+
Looks up a matching rule for (module_type, parent_module_type, device_type)
|
|
17
|
+
and renames interfaces created by NetBox's template instantiation.
|
|
18
|
+
|
|
19
|
+
Only processes interfaces whose name still matches the raw bay position
|
|
20
|
+
(i.e., haven't been renamed yet), ensuring idempotency.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Number of interfaces renamed/created, or 0 if no rule matched.
|
|
24
|
+
"""
|
|
25
|
+
from dcim.models import Interface
|
|
26
|
+
|
|
27
|
+
module_type = module.module_type
|
|
28
|
+
|
|
29
|
+
# Determine parent module type (if installed inside another module)
|
|
30
|
+
parent_module_type = None
|
|
31
|
+
if module_bay.parent:
|
|
32
|
+
parent_bay = module_bay.parent
|
|
33
|
+
if hasattr(parent_bay, "installed_module") and parent_bay.installed_module:
|
|
34
|
+
parent_module_type = parent_bay.installed_module.module_type
|
|
35
|
+
|
|
36
|
+
# Look up rule: most specific first, then broader matches
|
|
37
|
+
device_type = module.device.device_type if module.device else None
|
|
38
|
+
rule = _find_matching_rule(module_type, parent_module_type, device_type)
|
|
39
|
+
|
|
40
|
+
if not rule:
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
variables = _build_variables(module_bay)
|
|
44
|
+
interfaces = list(Interface.objects.filter(module=module))
|
|
45
|
+
|
|
46
|
+
if not interfaces:
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
# Determine the raw interface name NetBox assigned from the template
|
|
50
|
+
# (the bay position). Only rename interfaces that still have this name.
|
|
51
|
+
raw_name = variables["bay_position"]
|
|
52
|
+
unrenamed = [i for i in interfaces if i.name == raw_name]
|
|
53
|
+
if not unrenamed:
|
|
54
|
+
return 0 # Already renamed (idempotent guard)
|
|
55
|
+
|
|
56
|
+
renamed = 0
|
|
57
|
+
for iface in unrenamed:
|
|
58
|
+
variables["base"] = iface.name
|
|
59
|
+
renamed += _apply_rule_to_interface(rule, iface, variables, module)
|
|
60
|
+
|
|
61
|
+
return renamed
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _find_matching_rule(module_type, parent_module_type, device_type):
|
|
65
|
+
"""Find the most specific InterfaceNameRule matching the context.
|
|
66
|
+
|
|
67
|
+
Priority order:
|
|
68
|
+
1. module_type + parent_module_type + device_type
|
|
69
|
+
2. module_type + parent_module_type (any device)
|
|
70
|
+
3. module_type + device_type (any parent)
|
|
71
|
+
4. module_type only (universal)
|
|
72
|
+
"""
|
|
73
|
+
from .models import InterfaceNameRule
|
|
74
|
+
|
|
75
|
+
candidates = [
|
|
76
|
+
(parent_module_type, device_type),
|
|
77
|
+
(parent_module_type, None),
|
|
78
|
+
(None, device_type),
|
|
79
|
+
(None, None),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
for pmt, dt in candidates:
|
|
83
|
+
if pmt is None and dt is None:
|
|
84
|
+
# Most generic — both nullable
|
|
85
|
+
rule = InterfaceNameRule.objects.filter(
|
|
86
|
+
module_type=module_type,
|
|
87
|
+
parent_module_type__isnull=True,
|
|
88
|
+
device_type__isnull=True,
|
|
89
|
+
).first()
|
|
90
|
+
elif pmt is None:
|
|
91
|
+
rule = InterfaceNameRule.objects.filter(
|
|
92
|
+
module_type=module_type,
|
|
93
|
+
parent_module_type__isnull=True,
|
|
94
|
+
device_type=dt,
|
|
95
|
+
).first()
|
|
96
|
+
elif dt is None:
|
|
97
|
+
rule = InterfaceNameRule.objects.filter(
|
|
98
|
+
module_type=module_type,
|
|
99
|
+
parent_module_type=pmt,
|
|
100
|
+
device_type__isnull=True,
|
|
101
|
+
).first()
|
|
102
|
+
else:
|
|
103
|
+
rule = InterfaceNameRule.objects.filter(
|
|
104
|
+
module_type=module_type,
|
|
105
|
+
parent_module_type=pmt,
|
|
106
|
+
device_type=dt,
|
|
107
|
+
).first()
|
|
108
|
+
if rule:
|
|
109
|
+
return rule
|
|
110
|
+
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _build_variables(module_bay):
|
|
115
|
+
"""Build template variable dict from module bay context."""
|
|
116
|
+
bay_position = module_bay.position or "0"
|
|
117
|
+
# If position is a template expression (e.g., {module}), extract from bay name
|
|
118
|
+
if bay_position.startswith("{"):
|
|
119
|
+
match = re.search(r"(\d+)$", module_bay.name)
|
|
120
|
+
bay_position = match.group(1) if match else "0"
|
|
121
|
+
# Extract numeric-only version for arithmetic (e.g., "swp1" → "1")
|
|
122
|
+
bay_position_num_match = re.search(r"(\d+)$", bay_position)
|
|
123
|
+
bay_position_num = bay_position_num_match.group(1) if bay_position_num_match else bay_position
|
|
124
|
+
|
|
125
|
+
parent_bay_position = "0"
|
|
126
|
+
sfp_slot = bay_position_num
|
|
127
|
+
slot = bay_position_num
|
|
128
|
+
|
|
129
|
+
if module_bay.parent:
|
|
130
|
+
parent_bay = module_bay.parent
|
|
131
|
+
parent_bay_position = parent_bay.position or "0"
|
|
132
|
+
# slot is typically the top-level module position
|
|
133
|
+
if parent_bay.parent and hasattr(parent_bay.parent, "installed_module"):
|
|
134
|
+
grandparent = parent_bay.parent
|
|
135
|
+
slot = grandparent.position or parent_bay_position
|
|
136
|
+
else:
|
|
137
|
+
slot = parent_bay_position
|
|
138
|
+
elif hasattr(module_bay, "module") and module_bay.module:
|
|
139
|
+
# Bay belongs to an installed module (not nested, but module-scoped)
|
|
140
|
+
owner_module = module_bay.module
|
|
141
|
+
if hasattr(owner_module, "module_bay") and owner_module.module_bay:
|
|
142
|
+
slot = owner_module.module_bay.position or bay_position
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
"slot": slot,
|
|
146
|
+
"bay_position": bay_position,
|
|
147
|
+
"bay_position_num": bay_position_num,
|
|
148
|
+
"parent_bay_position": parent_bay_position,
|
|
149
|
+
"sfp_slot": sfp_slot,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _apply_rule_to_interface(rule, iface, variables, module):
|
|
154
|
+
"""Apply a single rule to an interface, handling breakout channels.
|
|
155
|
+
|
|
156
|
+
Returns number of interfaces renamed/created.
|
|
157
|
+
"""
|
|
158
|
+
from dcim.models import Interface
|
|
159
|
+
|
|
160
|
+
count = 0
|
|
161
|
+
|
|
162
|
+
if rule.channel_count > 0:
|
|
163
|
+
# Breakout: rename base and create additional channel interfaces
|
|
164
|
+
for ch in range(rule.channel_count):
|
|
165
|
+
variables["channel"] = str(rule.channel_start + ch)
|
|
166
|
+
new_name = evaluate_name_template(rule.name_template, variables)
|
|
167
|
+
if ch == 0:
|
|
168
|
+
iface.name = new_name
|
|
169
|
+
iface.full_clean()
|
|
170
|
+
iface.save()
|
|
171
|
+
count += 1
|
|
172
|
+
else:
|
|
173
|
+
breakout_iface = Interface(
|
|
174
|
+
device=module.device,
|
|
175
|
+
module=module,
|
|
176
|
+
name=new_name,
|
|
177
|
+
type=iface.type,
|
|
178
|
+
enabled=iface.enabled,
|
|
179
|
+
)
|
|
180
|
+
breakout_iface.full_clean()
|
|
181
|
+
breakout_iface.save()
|
|
182
|
+
count += 1
|
|
183
|
+
else:
|
|
184
|
+
# Simple rename (converter offset, platform naming, etc.)
|
|
185
|
+
new_name = evaluate_name_template(rule.name_template, variables)
|
|
186
|
+
if new_name != iface.name:
|
|
187
|
+
iface.name = new_name
|
|
188
|
+
iface.full_clean()
|
|
189
|
+
iface.save()
|
|
190
|
+
count += 1
|
|
191
|
+
|
|
192
|
+
return count
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def evaluate_name_template(template: str, variables: dict) -> str:
|
|
196
|
+
"""Evaluate a name template with variable substitution and arithmetic.
|
|
197
|
+
|
|
198
|
+
Supports templates like:
|
|
199
|
+
"GigabitEthernet{slot}/{8 + ({parent_bay_position} - 1) * 2 + {sfp_slot}}"
|
|
200
|
+
|
|
201
|
+
Variables are substituted first, then any brace-enclosed expression
|
|
202
|
+
containing arithmetic operators is safely evaluated via AST.
|
|
203
|
+
"""
|
|
204
|
+
# First pass: substitute all simple variables
|
|
205
|
+
result = template
|
|
206
|
+
for key, value in variables.items():
|
|
207
|
+
result = result.replace(f"{{{key}}}", str(value))
|
|
208
|
+
|
|
209
|
+
# Second pass: evaluate any remaining brace-enclosed arithmetic expressions
|
|
210
|
+
def _eval_expr(match):
|
|
211
|
+
expr = match.group(1).strip()
|
|
212
|
+
# Only allow digits, arithmetic operators, parentheses, and whitespace
|
|
213
|
+
if not re.match(r"^[\d\s\+\-\*\/\(\)]+$", expr):
|
|
214
|
+
raise ValueError(f"Unsafe expression in name template: {expr}")
|
|
215
|
+
try:
|
|
216
|
+
node = ast.parse(expr, mode="eval")
|
|
217
|
+
for child in ast.walk(node):
|
|
218
|
+
if not isinstance(
|
|
219
|
+
child,
|
|
220
|
+
(
|
|
221
|
+
ast.Expression,
|
|
222
|
+
ast.BinOp,
|
|
223
|
+
ast.UnaryOp,
|
|
224
|
+
ast.Constant,
|
|
225
|
+
ast.Add,
|
|
226
|
+
ast.Sub,
|
|
227
|
+
ast.Mult,
|
|
228
|
+
ast.Div,
|
|
229
|
+
ast.FloorDiv,
|
|
230
|
+
ast.Mod,
|
|
231
|
+
ast.USub,
|
|
232
|
+
ast.UAdd,
|
|
233
|
+
),
|
|
234
|
+
):
|
|
235
|
+
raise ValueError(f"Unsafe AST node in expression: {type(child).__name__}")
|
|
236
|
+
return str(eval(compile(node, "<template>", "eval"))) # noqa: S307
|
|
237
|
+
except (SyntaxError, TypeError) as e:
|
|
238
|
+
raise ValueError(f"Invalid arithmetic expression '{expr}': {e}") from e
|
|
239
|
+
|
|
240
|
+
result = re.sub(r"\{([^}]+)\}", _eval_expr, result)
|
|
241
|
+
return result
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
|
|
3
|
+
import django_filters
|
|
4
|
+
|
|
5
|
+
from .models import InterfaceNameRule
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InterfaceNameRuleFilterSet(django_filters.FilterSet):
|
|
9
|
+
class Meta:
|
|
10
|
+
model = InterfaceNameRule
|
|
11
|
+
fields = ["module_type", "parent_module_type", "device_type"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
|
|
3
|
+
from django import forms
|
|
4
|
+
from netbox.forms import NetBoxModelFilterSetForm, NetBoxModelForm, NetBoxModelImportForm
|
|
5
|
+
|
|
6
|
+
from .models import InterfaceNameRule
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InterfaceNameRuleForm(NetBoxModelForm):
|
|
10
|
+
class Meta:
|
|
11
|
+
model = InterfaceNameRule
|
|
12
|
+
fields = [
|
|
13
|
+
"module_type",
|
|
14
|
+
"parent_module_type",
|
|
15
|
+
"device_type",
|
|
16
|
+
"name_template",
|
|
17
|
+
"channel_count",
|
|
18
|
+
"channel_start",
|
|
19
|
+
"description",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class InterfaceNameRuleImportForm(NetBoxModelImportForm):
|
|
24
|
+
class Meta:
|
|
25
|
+
model = InterfaceNameRule
|
|
26
|
+
fields = [
|
|
27
|
+
"module_type",
|
|
28
|
+
"parent_module_type",
|
|
29
|
+
"device_type",
|
|
30
|
+
"name_template",
|
|
31
|
+
"channel_count",
|
|
32
|
+
"channel_start",
|
|
33
|
+
"description",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InterfaceNameRuleFilterForm(NetBoxModelFilterSetForm):
|
|
38
|
+
module_type_id = forms.IntegerField(required=False, label="Module Type ID")
|
|
39
|
+
parent_module_type_id = forms.IntegerField(required=False, label="Parent Module Type ID")
|
|
40
|
+
device_type_id = forms.IntegerField(required=False, label="Parent Device Type ID")
|
|
41
|
+
|
|
42
|
+
model = InterfaceNameRule
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import django.db.models.deletion
|
|
2
|
+
import netbox.models.deletion
|
|
3
|
+
import taggit.managers
|
|
4
|
+
import utilities.json
|
|
5
|
+
from django.db import migrations, models
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Migration(migrations.Migration):
|
|
9
|
+
initial = True
|
|
10
|
+
|
|
11
|
+
dependencies = [
|
|
12
|
+
("dcim", "0225_gfk_indexes"),
|
|
13
|
+
("extras", "0134_owner"),
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
operations = [
|
|
17
|
+
migrations.CreateModel(
|
|
18
|
+
name="InterfaceNameRule",
|
|
19
|
+
fields=[
|
|
20
|
+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
|
21
|
+
("created", models.DateTimeField(auto_now_add=True, null=True)),
|
|
22
|
+
("last_updated", models.DateTimeField(auto_now=True, null=True)),
|
|
23
|
+
(
|
|
24
|
+
"custom_field_data",
|
|
25
|
+
models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
|
|
26
|
+
),
|
|
27
|
+
(
|
|
28
|
+
"name_template",
|
|
29
|
+
models.CharField(
|
|
30
|
+
max_length=255,
|
|
31
|
+
help_text=(
|
|
32
|
+
"Interface name template expression, e.g. "
|
|
33
|
+
"'GigabitEthernet{slot}/{8 + ({parent_bay_position} - 1) * 2 + {sfp_slot}}'"
|
|
34
|
+
),
|
|
35
|
+
),
|
|
36
|
+
),
|
|
37
|
+
(
|
|
38
|
+
"channel_count",
|
|
39
|
+
models.PositiveSmallIntegerField(
|
|
40
|
+
default=0,
|
|
41
|
+
help_text="Number of breakout channels (0 = no breakout). Creates this many interfaces per template.",
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
(
|
|
45
|
+
"channel_start",
|
|
46
|
+
models.PositiveSmallIntegerField(
|
|
47
|
+
default=0,
|
|
48
|
+
help_text="Starting channel number for breakout interfaces (e.g., 0 for Juniper, 1 for Cisco)",
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
(
|
|
52
|
+
"description",
|
|
53
|
+
models.TextField(blank=True, help_text="Optional description or notes about this rule"),
|
|
54
|
+
),
|
|
55
|
+
(
|
|
56
|
+
"module_type",
|
|
57
|
+
models.ForeignKey(
|
|
58
|
+
on_delete=django.db.models.deletion.CASCADE,
|
|
59
|
+
related_name="+",
|
|
60
|
+
to="dcim.moduletype",
|
|
61
|
+
help_text="The module type whose installation triggers this rename rule",
|
|
62
|
+
),
|
|
63
|
+
),
|
|
64
|
+
(
|
|
65
|
+
"parent_module_type",
|
|
66
|
+
models.ForeignKey(
|
|
67
|
+
blank=True,
|
|
68
|
+
null=True,
|
|
69
|
+
on_delete=django.db.models.deletion.CASCADE,
|
|
70
|
+
related_name="+",
|
|
71
|
+
to="dcim.moduletype",
|
|
72
|
+
help_text="If set, rule only applies when installed inside this parent module type",
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
(
|
|
76
|
+
"device_type",
|
|
77
|
+
models.ForeignKey(
|
|
78
|
+
blank=True,
|
|
79
|
+
null=True,
|
|
80
|
+
on_delete=django.db.models.deletion.CASCADE,
|
|
81
|
+
related_name="+",
|
|
82
|
+
to="dcim.devicetype",
|
|
83
|
+
help_text="If set, rule only applies to devices of this parent device type",
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
(
|
|
87
|
+
"tags",
|
|
88
|
+
taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag", related_name="+"),
|
|
89
|
+
),
|
|
90
|
+
],
|
|
91
|
+
options={
|
|
92
|
+
"ordering": ["module_type__model"],
|
|
93
|
+
"unique_together": {("module_type", "parent_module_type", "device_type")},
|
|
94
|
+
},
|
|
95
|
+
bases=(netbox.models.deletion.DeleteMixin, models.Model),
|
|
96
|
+
),
|
|
97
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
|
|
3
|
+
from dcim.models import DeviceType, ModuleType
|
|
4
|
+
from django.db import models
|
|
5
|
+
from django.urls import reverse
|
|
6
|
+
from netbox.models import NetBoxModel
|
|
7
|
+
from taggit.managers import TaggableManager
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InterfaceNameRule(NetBoxModel):
|
|
11
|
+
"""Post-install interface rename rule for module types.
|
|
12
|
+
|
|
13
|
+
Handles cases where NetBox's position-based naming can't produce
|
|
14
|
+
the correct interface name, such as converter offset (CVR-X2-SFP)
|
|
15
|
+
or breakout transceivers (QSFP+ 4x10G).
|
|
16
|
+
|
|
17
|
+
The name_template uses Python str.format() syntax with these variables:
|
|
18
|
+
{slot} - Slot number from parent module bay position
|
|
19
|
+
{bay_position} - Position of the bay this module is installed into
|
|
20
|
+
{bay_position_num} - Numeric suffix of bay position (e.g., "swp1" → "1")
|
|
21
|
+
{parent_bay_position} - Position of the parent module's bay
|
|
22
|
+
{sfp_slot} - Sub-bay index within the parent module
|
|
23
|
+
{base} - Base interface name from NetBox position resolution
|
|
24
|
+
{channel} - Channel number (iterated for breakout)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
module_type = models.ForeignKey(
|
|
28
|
+
ModuleType,
|
|
29
|
+
on_delete=models.CASCADE,
|
|
30
|
+
related_name="+",
|
|
31
|
+
help_text="The module type whose installation triggers this rename rule",
|
|
32
|
+
)
|
|
33
|
+
parent_module_type = models.ForeignKey(
|
|
34
|
+
ModuleType,
|
|
35
|
+
on_delete=models.CASCADE,
|
|
36
|
+
null=True,
|
|
37
|
+
blank=True,
|
|
38
|
+
related_name="+",
|
|
39
|
+
help_text="If set, rule only applies when installed inside this parent module type",
|
|
40
|
+
)
|
|
41
|
+
device_type = models.ForeignKey(
|
|
42
|
+
DeviceType,
|
|
43
|
+
on_delete=models.CASCADE,
|
|
44
|
+
null=True,
|
|
45
|
+
blank=True,
|
|
46
|
+
related_name="+",
|
|
47
|
+
help_text="If set, rule only applies to devices of this parent device type",
|
|
48
|
+
)
|
|
49
|
+
name_template = models.CharField(
|
|
50
|
+
max_length=255,
|
|
51
|
+
help_text=(
|
|
52
|
+
"Interface name template expression, e.g. "
|
|
53
|
+
"'GigabitEthernet{slot}/{8 + ({parent_bay_position} - 1) * 2 + {sfp_slot}}'"
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
channel_count = models.PositiveSmallIntegerField(
|
|
57
|
+
default=0,
|
|
58
|
+
help_text="Number of breakout channels (0 = no breakout). Creates this many interfaces per template.",
|
|
59
|
+
)
|
|
60
|
+
channel_start = models.PositiveSmallIntegerField(
|
|
61
|
+
default=0,
|
|
62
|
+
help_text="Starting channel number for breakout interfaces (e.g., 0 for Juniper, 1 for Cisco)",
|
|
63
|
+
)
|
|
64
|
+
description = models.TextField(
|
|
65
|
+
blank=True,
|
|
66
|
+
help_text="Optional description or notes about this rule",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Override inherited tags to avoid reverse accessor clash when co-installed
|
|
70
|
+
# with another plugin that has a model of the same name.
|
|
71
|
+
tags = TaggableManager(through="extras.TaggedItem", related_name="+")
|
|
72
|
+
|
|
73
|
+
def get_absolute_url(self):
|
|
74
|
+
return reverse("plugins:netbox_interface_name_rules:interfacenamerule_detail", args=[self.pk])
|
|
75
|
+
|
|
76
|
+
class Meta:
|
|
77
|
+
unique_together = ["module_type", "parent_module_type", "device_type"]
|
|
78
|
+
ordering = ["module_type__model"]
|
|
79
|
+
|
|
80
|
+
def __str__(self):
|
|
81
|
+
parent = f" in {self.parent_module_type.model}" if self.parent_module_type else ""
|
|
82
|
+
device = f" on {self.device_type.model}" if self.device_type else ""
|
|
83
|
+
return f"{self.module_type.model}{parent}{device} → {self.name_template}"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
|
|
3
|
+
from netbox.plugins import PluginMenu, PluginMenuButton, PluginMenuItem
|
|
4
|
+
|
|
5
|
+
menu = PluginMenu(
|
|
6
|
+
label="Interface Name Rules",
|
|
7
|
+
icon_class="mdi mdi-swap-horizontal",
|
|
8
|
+
groups=(
|
|
9
|
+
(
|
|
10
|
+
"Rules",
|
|
11
|
+
(
|
|
12
|
+
PluginMenuItem(
|
|
13
|
+
link="plugins:netbox_interface_name_rules:interfacenamerule_list",
|
|
14
|
+
link_text="Interface Name Rules",
|
|
15
|
+
buttons=(
|
|
16
|
+
PluginMenuButton(
|
|
17
|
+
link="plugins:netbox_interface_name_rules:interfacenamerule_add",
|
|
18
|
+
title="Add",
|
|
19
|
+
icon_class="mdi mdi-plus-thick",
|
|
20
|
+
),
|
|
21
|
+
PluginMenuButton(
|
|
22
|
+
link="plugins:netbox_interface_name_rules:interfacenamerule_bulk_import",
|
|
23
|
+
title="Import",
|
|
24
|
+
icon_class="mdi mdi-upload",
|
|
25
|
+
),
|
|
26
|
+
),
|
|
27
|
+
),
|
|
28
|
+
),
|
|
29
|
+
),
|
|
30
|
+
),
|
|
31
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
|
|
3
|
+
"""Django signals for automatic interface renaming on module install."""
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import logging
|
|
7
|
+
import threading
|
|
8
|
+
|
|
9
|
+
from django.db import connection, transaction
|
|
10
|
+
from django.db.models.signals import post_save
|
|
11
|
+
from django.dispatch import receiver
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("netbox_interface_name_rules")
|
|
14
|
+
|
|
15
|
+
# Delay for auto-commit mode (seconds). Allows Module.save() to finish
|
|
16
|
+
# creating component instances before the rename logic runs.
|
|
17
|
+
_AUTOCOMMIT_DELAY = 0.5
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@receiver(post_save, sender="dcim.Module", dispatch_uid="interface_name_rules_post_save_module")
|
|
21
|
+
def on_module_saved(sender, instance, created, **kwargs):
|
|
22
|
+
"""Apply interface name rules when a module is installed (created).
|
|
23
|
+
|
|
24
|
+
NetBox creates module component instances (interfaces) in Module.save()
|
|
25
|
+
*after* super().save() returns — the point at which post_save fires.
|
|
26
|
+
|
|
27
|
+
In an explicit transaction (UI/API with atomic block),
|
|
28
|
+
transaction.on_commit() correctly defers execution until after the
|
|
29
|
+
full save completes, including interface creation.
|
|
30
|
+
|
|
31
|
+
In auto-commit mode, on_commit() fires immediately — a short timer
|
|
32
|
+
is used as a fallback to allow Module.save() to finish.
|
|
33
|
+
"""
|
|
34
|
+
if not created:
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
module = instance
|
|
38
|
+
module_bay = getattr(module, "module_bay", None)
|
|
39
|
+
if not module_bay:
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
callback = functools.partial(_apply_rules_deferred, module.pk, module_bay.pk)
|
|
43
|
+
|
|
44
|
+
if connection.in_atomic_block:
|
|
45
|
+
transaction.on_commit(callback)
|
|
46
|
+
else:
|
|
47
|
+
threading.Timer(_AUTOCOMMIT_DELAY, callback).start()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _apply_rules_deferred(module_pk, module_bay_pk):
|
|
51
|
+
"""Apply interface name rules after transaction commit."""
|
|
52
|
+
from dcim.models import Module, ModuleBay
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
module = Module.objects.get(pk=module_pk)
|
|
56
|
+
module_bay = ModuleBay.objects.get(pk=module_bay_pk)
|
|
57
|
+
except (Module.DoesNotExist, ModuleBay.DoesNotExist):
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
from .engine import apply_interface_name_rules
|
|
62
|
+
|
|
63
|
+
renamed = apply_interface_name_rules(module, module_bay)
|
|
64
|
+
if renamed:
|
|
65
|
+
logger.info(
|
|
66
|
+
"Renamed %d interface(s) for %s in %s",
|
|
67
|
+
renamed,
|
|
68
|
+
module.module_type,
|
|
69
|
+
module_bay.name,
|
|
70
|
+
)
|
|
71
|
+
except Exception:
|
|
72
|
+
logger.exception(
|
|
73
|
+
"Failed to apply interface name rules for %s in %s",
|
|
74
|
+
module.module_type,
|
|
75
|
+
module_bay.name,
|
|
76
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
|
|
3
|
+
import django_tables2 as tables
|
|
4
|
+
from netbox.tables import NetBoxTable, columns
|
|
5
|
+
|
|
6
|
+
from .models import InterfaceNameRule
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InterfaceNameRuleTable(NetBoxTable):
|
|
10
|
+
pk = columns.ToggleColumn()
|
|
11
|
+
module_type = tables.Column(verbose_name="Module Type", linkify=True)
|
|
12
|
+
parent_module_type = tables.Column(verbose_name="Parent Module Type", linkify=True)
|
|
13
|
+
device_type = tables.Column(verbose_name="Parent Device Type", linkify=True)
|
|
14
|
+
name_template = tables.Column(verbose_name="Name Template")
|
|
15
|
+
channel_count = tables.Column(verbose_name="Channels")
|
|
16
|
+
channel_start = tables.Column(verbose_name="Ch. Start")
|
|
17
|
+
description = tables.Column(verbose_name="Description", linkify=False)
|
|
18
|
+
actions = columns.ActionsColumn(actions=("edit", "delete"))
|
|
19
|
+
|
|
20
|
+
class Meta:
|
|
21
|
+
model = InterfaceNameRule
|
|
22
|
+
fields = (
|
|
23
|
+
"pk",
|
|
24
|
+
"id",
|
|
25
|
+
"module_type",
|
|
26
|
+
"parent_module_type",
|
|
27
|
+
"device_type",
|
|
28
|
+
"name_template",
|
|
29
|
+
"channel_count",
|
|
30
|
+
"channel_start",
|
|
31
|
+
"description",
|
|
32
|
+
"actions",
|
|
33
|
+
)
|
|
34
|
+
default_columns = (
|
|
35
|
+
"pk",
|
|
36
|
+
"module_type",
|
|
37
|
+
"parent_module_type",
|
|
38
|
+
"device_type",
|
|
39
|
+
"name_template",
|
|
40
|
+
"channel_count",
|
|
41
|
+
"description",
|
|
42
|
+
"actions",
|
|
43
|
+
)
|
|
44
|
+
attrs = {"class": "table table-hover table-headings table-striped"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
|
|
3
|
+
from django.urls import path
|
|
4
|
+
|
|
5
|
+
from . import views
|
|
6
|
+
|
|
7
|
+
app_name = "netbox_interface_name_rules"
|
|
8
|
+
|
|
9
|
+
urlpatterns = [
|
|
10
|
+
# List / CRUD
|
|
11
|
+
path("rules/", views.InterfaceNameRuleListView.as_view(), name="interfacenamerule_list"),
|
|
12
|
+
path("rules/add/", views.InterfaceNameRuleCreateView.as_view(), name="interfacenamerule_add"),
|
|
13
|
+
path("rules/import/", views.InterfaceNameRuleBulkImportView.as_view(), name="interfacenamerule_bulk_import"),
|
|
14
|
+
path("rules/bulk_delete/", views.InterfaceNameRuleBulkDeleteView.as_view(), name="interfacenamerule_bulk_delete"),
|
|
15
|
+
path("rules/<int:pk>/", views.InterfaceNameRuleView.as_view(), name="interfacenamerule_detail"),
|
|
16
|
+
path("rules/<int:pk>/edit/", views.InterfaceNameRuleEditView.as_view(), name="interfacenamerule_edit"),
|
|
17
|
+
path("rules/<int:pk>/delete/", views.InterfaceNameRuleDeleteView.as_view(), name="interfacenamerule_delete"),
|
|
18
|
+
path(
|
|
19
|
+
"rules/<int:pk>/changelog/",
|
|
20
|
+
views.InterfaceNameRuleChangeLogView.as_view(),
|
|
21
|
+
name="interfacenamerule_changelog",
|
|
22
|
+
),
|
|
23
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
|
|
3
|
+
"""Version-detection utilities for the interface name rules plugin."""
|
|
4
|
+
|
|
5
|
+
MODULE_PATH_MIN_VERSION = "4.9.0"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def supports_module_path():
|
|
9
|
+
"""Check if the running NetBox version supports the {module_path} template token."""
|
|
10
|
+
from django.conf import settings
|
|
11
|
+
|
|
12
|
+
version_str = getattr(settings, "VERSION", "0.0.0")
|
|
13
|
+
version_str = version_str.split("-")[0]
|
|
14
|
+
try:
|
|
15
|
+
current = tuple(int(x) for x in version_str.split("."))
|
|
16
|
+
required = tuple(int(x) for x in MODULE_PATH_MIN_VERSION.split("."))
|
|
17
|
+
return current >= required
|
|
18
|
+
except (ValueError, TypeError):
|
|
19
|
+
return False
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
|
|
3
|
+
from netbox.views import generic
|
|
4
|
+
from utilities.views import register_model_view
|
|
5
|
+
|
|
6
|
+
from .filters import InterfaceNameRuleFilterSet
|
|
7
|
+
from .forms import InterfaceNameRuleFilterForm, InterfaceNameRuleForm, InterfaceNameRuleImportForm
|
|
8
|
+
from .models import InterfaceNameRule
|
|
9
|
+
from .tables import InterfaceNameRuleTable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InterfaceNameRuleListView(generic.ObjectListView):
|
|
13
|
+
queryset = InterfaceNameRule.objects.all()
|
|
14
|
+
table = InterfaceNameRuleTable
|
|
15
|
+
filterset = InterfaceNameRuleFilterSet
|
|
16
|
+
filterset_form = InterfaceNameRuleFilterForm
|
|
17
|
+
template_name = "netbox_interface_name_rules/interfacenamerule_list.html"
|
|
18
|
+
|
|
19
|
+
def get_extra_context(self, request):
|
|
20
|
+
from .utils import MODULE_PATH_MIN_VERSION, supports_module_path
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
"supports_module_path": supports_module_path(),
|
|
24
|
+
"module_path_min_version": MODULE_PATH_MIN_VERSION,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InterfaceNameRuleCreateView(generic.ObjectEditView):
|
|
29
|
+
queryset = InterfaceNameRule.objects.all()
|
|
30
|
+
form = InterfaceNameRuleForm
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@register_model_view(InterfaceNameRule, "bulk_import", path="import", detail=False)
|
|
34
|
+
class InterfaceNameRuleBulkImportView(generic.BulkImportView):
|
|
35
|
+
queryset = InterfaceNameRule.objects.all()
|
|
36
|
+
model_form = InterfaceNameRuleImportForm
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class InterfaceNameRuleView(generic.ObjectView):
|
|
40
|
+
queryset = InterfaceNameRule.objects.all()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class InterfaceNameRuleEditView(generic.ObjectEditView):
|
|
44
|
+
queryset = InterfaceNameRule.objects.all()
|
|
45
|
+
form = InterfaceNameRuleForm
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class InterfaceNameRuleDeleteView(generic.ObjectDeleteView):
|
|
49
|
+
queryset = InterfaceNameRule.objects.all()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class InterfaceNameRuleBulkDeleteView(generic.BulkDeleteView):
|
|
53
|
+
queryset = InterfaceNameRule.objects.all()
|
|
54
|
+
table = InterfaceNameRuleTable
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class InterfaceNameRuleChangeLogView(generic.ObjectChangeLogView):
|
|
58
|
+
queryset = InterfaceNameRule.objects.all()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: netbox-interface-name-rules
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: NetBox plugin for automatic interface renaming when modules are installed
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.12.0
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Dynamic: license-file
|
|
10
|
+
|
|
11
|
+
# NetBox Interface Name Rules Plugin
|
|
12
|
+
|
|
13
|
+
Automatic interface renaming when modules are installed into NetBox device bays.
|
|
14
|
+
|
|
15
|
+
## What it does
|
|
16
|
+
|
|
17
|
+
When a module (transceiver, line card, converter) is installed into a module bay,
|
|
18
|
+
NetBox creates interfaces using position-based naming from the module type template.
|
|
19
|
+
This often produces incorrect names — e.g., `Interface 1` instead of `et-0/0/4`.
|
|
20
|
+
|
|
21
|
+
This plugin hooks into Django's `post_save` signal on the `Module` model to
|
|
22
|
+
automatically apply renaming rules based on configurable templates.
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
- **Signal-driven** — rules fire automatically on module install, no manual step needed
|
|
27
|
+
- **Template variables** — `{slot}`, `{bay_position}`, `{bay_position_num}`, `{base}`, `{channel}`, etc.
|
|
28
|
+
- **Arithmetic expressions** — `{8 + ({parent_bay_position} - 1) * 2 + {sfp_slot}}`
|
|
29
|
+
- **Breakout support** — create multiple channel interfaces from a single port (e.g., QSFP+ 4x10G)
|
|
30
|
+
- **Scoping** — rules can be scoped to specific device types, parent module types, or be universal
|
|
31
|
+
- **Bulk import/export** — YAML-based rule management via the UI or API
|
|
32
|
+
|
|
33
|
+
## Supported scenarios
|
|
34
|
+
|
|
35
|
+
| Scenario | Example |
|
|
36
|
+
|----------|---------|
|
|
37
|
+
| Converter offset | GLC-T in CVR-X2-SFP → `GigabitEthernet3/10` |
|
|
38
|
+
| Breakout channels | QSFP-4X10G-LR → `et-0/0/4:0` through `et-0/0/4:3` |
|
|
39
|
+
| Platform naming | QSFP-100G-LR4 on ACX7024 → `et-0/0/{bay_position}` |
|
|
40
|
+
| UfiSpace breakout | QSFP-100G on S9610 → `swp{bay_position_num}s{channel}` |
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install netbox-interface-name-rules
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Add to `configuration.py`:
|
|
49
|
+
```python
|
|
50
|
+
PLUGINS = ['netbox_interface_name_rules']
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Compatibility
|
|
54
|
+
|
|
55
|
+
- NetBox ≥ 4.2.0
|
|
56
|
+
- Python ≥ 3.12
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
Apache 2.0
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
netbox_interface_name_rules/__init__.py
|
|
5
|
+
netbox_interface_name_rules/engine.py
|
|
6
|
+
netbox_interface_name_rules/filters.py
|
|
7
|
+
netbox_interface_name_rules/forms.py
|
|
8
|
+
netbox_interface_name_rules/models.py
|
|
9
|
+
netbox_interface_name_rules/navigation.py
|
|
10
|
+
netbox_interface_name_rules/signals.py
|
|
11
|
+
netbox_interface_name_rules/tables.py
|
|
12
|
+
netbox_interface_name_rules/urls.py
|
|
13
|
+
netbox_interface_name_rules/utils.py
|
|
14
|
+
netbox_interface_name_rules/views.py
|
|
15
|
+
netbox_interface_name_rules.egg-info/PKG-INFO
|
|
16
|
+
netbox_interface_name_rules.egg-info/SOURCES.txt
|
|
17
|
+
netbox_interface_name_rules.egg-info/dependency_links.txt
|
|
18
|
+
netbox_interface_name_rules.egg-info/top_level.txt
|
|
19
|
+
netbox_interface_name_rules/api/__init__.py
|
|
20
|
+
netbox_interface_name_rules/api/serializers.py
|
|
21
|
+
netbox_interface_name_rules/api/urls.py
|
|
22
|
+
netbox_interface_name_rules/api/views.py
|
|
23
|
+
netbox_interface_name_rules/migrations/0001_initial.py
|
|
24
|
+
netbox_interface_name_rules/migrations/__init__.py
|
|
25
|
+
netbox_interface_name_rules/tests/__init__.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
netbox_interface_name_rules
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
|
|
3
|
+
[build-system]
|
|
4
|
+
requires = ["setuptools"]
|
|
5
|
+
build-backend = "setuptools.build_meta"
|
|
6
|
+
|
|
7
|
+
[project]
|
|
8
|
+
name = "netbox-interface-name-rules"
|
|
9
|
+
version = "1.0.0"
|
|
10
|
+
description = "NetBox plugin for automatic interface renaming when modules are installed"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.12.0"
|
|
13
|
+
license = "Apache-2.0"
|
|
14
|
+
|
|
15
|
+
[tool.setuptools.packages.find]
|
|
16
|
+
include = ["netbox_interface_name_rules*"]
|
|
17
|
+
|
|
18
|
+
[dependency-groups]
|
|
19
|
+
dev = [
|
|
20
|
+
"build",
|
|
21
|
+
"reuse",
|
|
22
|
+
"ruff",
|
|
23
|
+
"pre-commit",
|
|
24
|
+
"pytest",
|
|
25
|
+
"pytest-django",
|
|
26
|
+
"python-semantic-release",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
pythonpath = ["/opt/netbox/netbox"]
|
|
31
|
+
|
|
32
|
+
[tool.ruff]
|
|
33
|
+
line-length = 120
|
|
34
|
+
|
|
35
|
+
[tool.ruff.lint]
|
|
36
|
+
ignore = ["E501", "F403", "F405"]
|
|
37
|
+
|
|
38
|
+
[tool.ruff.lint.per-file-ignores]
|
|
39
|
+
"__init__.py" = ["F401"]
|
|
40
|
+
|
|
41
|
+
[tool.semantic_release]
|
|
42
|
+
version_toml = ["pyproject.toml:project.version"]
|
|
43
|
+
version_variables = ["netbox_interface_name_rules/__init__.py:__version__"]
|
|
44
|
+
branch = "main"
|
|
45
|
+
commit_message = "chore(release): {version}\n\nAutomatically generated by python-semantic-release"
|
|
46
|
+
build_command = "python -m build"
|
|
47
|
+
upload_to_vcs_release = true
|
|
48
|
+
|
|
49
|
+
[tool.semantic_release.changelog]
|
|
50
|
+
changelog_file = "CHANGELOG.md"
|
|
51
|
+
|
|
52
|
+
[tool.semantic_release.commit_parser_options]
|
|
53
|
+
allowed_tags = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"]
|
|
54
|
+
minor_tags = ["feat"]
|
|
55
|
+
patch_tags = ["fix", "perf"]
|