diff --git a/docs/models/dcim/devicetype.md b/docs/models/dcim/devicetype.md
index fc177542c..4f75aff09 100644
--- a/docs/models/dcim/devicetype.md
+++ b/docs/models/dcim/devicetype.md
@@ -7,6 +7,18 @@ Device types are instantiated as devices installed within sites and/or equipment
!!! note
This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. Instead, line cards and similarly non-autonomous hardware should be modeled as modules or inventory items within a device.
+## Automatic Component Renaming
+
+When adding component templates to a device type, the string `{vc_position}` can be used in component template names to reference the
+`vc_position` field of the device being provisioned, when that device is a member of a Virtual Chassis.
+
+For example, an interface template named `Gi{vc_position}/0/0` installed on a Virtual Chassis
+member with position `2` will be rendered as `Gi2/0/0`.
+
+If the device is not a member of a Virtual Chassis, `{vc_position}` defaults to `0`. A custom
+fallback value can be specified using the syntax `{vc_position:X}`, where `X` is the desired default.
+For example, `{vc_position:1}` will render as `1` when no Virtual Chassis position is set.
+
## Fields
### Manufacturer
diff --git a/docs/models/dcim/moduletype.md b/docs/models/dcim/moduletype.md
index 88f04466a..790e4a8de 100644
--- a/docs/models/dcim/moduletype.md
+++ b/docs/models/dcim/moduletype.md
@@ -20,6 +20,16 @@ When adding component templates to a module type, the string `{module}` can be u
For example, you can create a module type with interface templates named `Gi{module}/0/[1-48]`. When a new module of this type is "installed" to a module bay with a position of "3", NetBox will automatically name these interfaces `Gi3/0/[1-48]`.
+Similarly, the string `{vc_position}` can be used in component template names to reference the
+`vc_position` field of the device being provisioned, when that device is a member of a Virtual Chassis.
+
+For example, an interface template named `Gi{vc_position}/{module}/0` installed on a Virtual Chassis
+member with position `2` and module bay position `3` will be rendered as `Gi2/3/0`.
+
+If the device is not a member of a Virtual Chassis, `{vc_position}` defaults to `0`. A custom
+fallback value can be specified using the syntax `{vc_position:X}`, where `X` is the desired default.
+For example, `{vc_position:1}` will render as `1` when no Virtual Chassis position is set.
+
Automatic renaming is supported for all modular component types (those listed above).
## Fields
diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py
index 669345d7c..87f8844e0 100644
--- a/netbox/dcim/constants.py
+++ b/netbox/dcim/constants.py
@@ -1,3 +1,5 @@
+import re
+
from django.db.models import Q
from .choices import InterfaceTypeChoices
@@ -79,6 +81,7 @@ NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
#
MODULE_TOKEN = '{module}'
+VC_POSITION_RE = re.compile(r'\{vc_position(?::([^}]*))?\}')
MODULAR_COMPONENT_TEMPLATE_MODELS = Q(
app_label='dcim',
diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py
index ca06b620b..8c715d369 100644
--- a/netbox/dcim/forms/model_forms.py
+++ b/netbox/dcim/forms/model_forms.py
@@ -1072,7 +1072,9 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
self.fields['name'].help_text = _(
"Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range are not "
"supported (example: [ge,xe]-0/0/[0-9]). The token {module}, if present, will be "
- "automatically replaced with the position value when creating a new module."
+ "automatically replaced with the position value when creating a new module. "
+ "The token {vc_position} will be replaced with the device's Virtual Chassis position "
+ "(use {vc_position:1} to specify a fallback (default is 0))"
)
diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py
index 0d54331d8..475eb5508 100644
--- a/netbox/dcim/models/device_component_templates.py
+++ b/netbox/dcim/models/device_component_templates.py
@@ -165,6 +165,26 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
_("A component template must be associated with either a device type or a module type.")
)
+ @staticmethod
+ def _resolve_vc_position(value: str, device) -> str:
+ """
+ Resolves {vc_position} and {vc_position:X} tokens.
+
+ If the device has a vc_position, replaces the token with that value.
+ Otherwise uses the explicit fallback X if given, else '0'.
+ """
+ def replacer(match):
+ explicit_fallback = match.group(1)
+ if (
+ device is not None
+ and device.virtual_chassis is not None
+ and device.vc_position is not None
+ ):
+ return str(device.vc_position)
+ return explicit_fallback if explicit_fallback is not None else '0'
+
+ return VC_POSITION_RE.sub(replacer, value)
+
def _get_module_tree(self, module):
modules = []
while module:
@@ -177,29 +197,42 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
modules.reverse()
return modules
- def resolve_name(self, module):
- if MODULE_TOKEN not in self.name:
+ def resolve_name(self, module=None, device=None):
+ has_module = MODULE_TOKEN in self.name
+ has_vc = VC_POSITION_RE.search(self.name) is not None
+ if not has_module and not has_vc:
return self.name
- if module:
- modules = self._get_module_tree(module)
- name = self.name
- for module in modules:
- name = name.replace(MODULE_TOKEN, module.module_bay.position, 1)
- return name
- return self.name
+ name = self.name
- def resolve_label(self, module):
- if MODULE_TOKEN not in self.label:
+ if has_module and module:
+ modules = self._get_module_tree(module)
+ for m in modules:
+ name = name.replace(MODULE_TOKEN, m.module_bay.position, 1)
+
+ if has_vc:
+ resolved_device = (module.device if module else None) or device
+ name = self._resolve_vc_position(name, resolved_device)
+
+ return name
+
+ def resolve_label(self, module=None, device=None):
+ has_module = MODULE_TOKEN in self.label
+ has_vc = VC_POSITION_RE.search(self.label) is not None
+ if not has_module and not has_vc:
return self.label
- if module:
+ label = self.label
+
+ if has_module and module:
modules = self._get_module_tree(module)
- label = self.label
- for module in modules:
- label = label.replace(MODULE_TOKEN, module.module_bay.position, 1)
- return label
- return self.label
+ for m in modules:
+ label = label.replace(MODULE_TOKEN, m.module_bay.position, 1)
+ if has_vc:
+ resolved_device = (module.device if module else None) or device
+ label = self._resolve_vc_position(label, resolved_device)
+
+ return label
class ConsolePortTemplate(ModularComponentTemplateModel):
@@ -222,8 +255,8 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs):
return self.component_model(
- name=self.resolve_name(kwargs.get('module')),
- label=self.resolve_label(kwargs.get('module')),
+ name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
+ label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
type=self.type,
**kwargs
)
@@ -257,8 +290,8 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs):
return self.component_model(
- name=self.resolve_name(kwargs.get('module')),
- label=self.resolve_label(kwargs.get('module')),
+ name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
+ label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
type=self.type,
**kwargs
)
@@ -307,8 +340,8 @@ class PowerPortTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs):
return self.component_model(
- name=self.resolve_name(kwargs.get('module')),
- label=self.resolve_label(kwargs.get('module')),
+ name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
+ label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
type=self.type,
maximum_draw=self.maximum_draw,
allocated_draw=self.allocated_draw,
@@ -395,13 +428,13 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs):
if self.power_port:
- power_port_name = self.power_port.resolve_name(kwargs.get('module'))
+ power_port_name = self.power_port.resolve_name(kwargs.get('module'), kwargs.get('device'))
power_port = PowerPort.objects.get(name=power_port_name, **kwargs)
else:
power_port = None
return self.component_model(
- name=self.resolve_name(kwargs.get('module')),
- label=self.resolve_label(kwargs.get('module')),
+ name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
+ label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
type=self.type,
color=self.color,
power_port=power_port,
@@ -501,8 +534,8 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
def instantiate(self, **kwargs):
return self.component_model(
- name=self.resolve_name(kwargs.get('module')),
- label=self.resolve_label(kwargs.get('module')),
+ name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
+ label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
type=self.type,
enabled=self.enabled,
mgmt_only=self.mgmt_only,
@@ -628,8 +661,8 @@ class FrontPortTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs):
return self.component_model(
- name=self.resolve_name(kwargs.get('module')),
- label=self.resolve_label(kwargs.get('module')),
+ name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
+ label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
type=self.type,
color=self.color,
positions=self.positions,
@@ -692,8 +725,8 @@ class RearPortTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs):
return self.component_model(
- name=self.resolve_name(kwargs.get('module')),
- label=self.resolve_label(kwargs.get('module')),
+ name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
+ label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
type=self.type,
color=self.color,
positions=self.positions,
@@ -731,8 +764,8 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs):
return self.component_model(
- name=self.resolve_name(kwargs.get('module')),
- label=self.resolve_label(kwargs.get('module')),
+ name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
+ label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
position=self.position,
**kwargs
)
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index 4a5d0b3cd..e205ac0c8 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -26,6 +26,7 @@ from netbox.config import ConfigItem
from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
from netbox.models.mixins import WeightMixin
+from utilities.exceptions import AbortRequest
from utilities.fields import ColorField, CounterCacheField
from utilities.prefetch import get_prefetchable_fields
from utilities.tracking import TrackingModelMixin
@@ -948,6 +949,20 @@ class Device(
).format(virtual_chassis=self.vc_master_for)
})
+ def _check_duplicate_component_names(self, components):
+ """
+ Check for duplicate component names after resolving {vc_position} placeholders.
+ Raises AbortRequest if duplicates are found.
+ """
+ names = [c.name for c in components]
+ duplicates = {n for n in names if names.count(n) > 1}
+ if duplicates:
+ raise AbortRequest(
+ _("Component name conflict after resolving {{vc_position}}: {names}").format(
+ names=', '.join(duplicates)
+ )
+ )
+
def _instantiate_components(self, queryset, bulk_create=True):
"""
Instantiate components for the device from the specified component templates.
@@ -962,6 +977,10 @@ class Device(
components = [obj.instantiate(device=self) for obj in queryset]
if not components:
return
+
+ # Check for duplicate names after resolution {vc_position}
+ self._check_duplicate_component_names(components)
+
# Set default values for any applicable custom fields
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
for component in components:
@@ -986,8 +1005,14 @@ class Device(
update_fields=None
)
else:
- for obj in queryset:
- component = obj.instantiate(device=self)
+ components = [obj.instantiate(device=self) for obj in queryset]
+ if not components:
+ return
+
+ # Check for duplicate names after resolution {vc_position}
+ self._check_duplicate_component_names(components)
+
+ for component in components:
# Set default values for any applicable custom fields
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
component.custom_field_data = cf_defaults
diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py
index 118c347fd..a11481d8a 100644
--- a/netbox/dcim/tests/test_forms.py
+++ b/netbox/dcim/tests/test_forms.py
@@ -11,6 +11,7 @@ from dcim.choices import (
from dcim.forms import *
from dcim.models import *
from ipam.models import VLAN
+from utilities.exceptions import AbortRequest
from utilities.testing import create_test_device
from virtualization.models import Cluster, ClusterGroup, ClusterType
@@ -175,6 +176,88 @@ class DeviceTestCase(TestCase):
self.assertIn('position', form.errors)
+class VCPositionTokenFormTestCase(TestCase):
+
+ @classmethod
+ def setUpTestData(cls):
+ Site.objects.create(name='Site VC 1', slug='site-vc-1')
+ manufacturer = Manufacturer.objects.create(name='Manufacturer VC 1', slug='manufacturer-vc-1')
+ device_type = DeviceType.objects.create(
+ manufacturer=manufacturer, model='Device Type VC 1', slug='device-type-vc-1'
+ )
+ DeviceRole.objects.create(name='Device Role VC 1', slug='device-role-vc-1', color='ff0000')
+ InterfaceTemplate.objects.create(
+ device_type=device_type,
+ name='ge-{vc_position:0}/0/0',
+ type='1000base-t',
+ )
+ VirtualChassis.objects.create(name='VC 1')
+
+ def test_device_creation_in_vc_resolves_vc_position(self):
+ form = DeviceForm(data={
+ 'name': 'Device VC Form 1',
+ 'role': DeviceRole.objects.first().pk,
+ 'tenant': None,
+ 'manufacturer': Manufacturer.objects.first().pk,
+ 'device_type': DeviceType.objects.first().pk,
+ 'site': Site.objects.first().pk,
+ 'rack': None,
+ 'face': None,
+ 'position': None,
+ 'platform': None,
+ 'status': DeviceStatusChoices.STATUS_ACTIVE,
+ 'virtual_chassis': VirtualChassis.objects.first().pk,
+ 'vc_position': 2,
+ })
+ self.assertTrue(form.is_valid())
+ device = form.save()
+ self.assertTrue(device.interfaces.filter(name='ge-2/0/0').exists())
+
+ def test_device_creation_not_in_vc_uses_fallback(self):
+ form = DeviceForm(data={
+ 'name': 'Device VC Form 2',
+ 'role': DeviceRole.objects.first().pk,
+ 'tenant': None,
+ 'manufacturer': Manufacturer.objects.first().pk,
+ 'device_type': DeviceType.objects.first().pk,
+ 'site': Site.objects.first().pk,
+ 'rack': None,
+ 'face': None,
+ 'position': None,
+ 'platform': None,
+ 'status': DeviceStatusChoices.STATUS_ACTIVE,
+ })
+ self.assertTrue(form.is_valid())
+ device = form.save()
+ self.assertTrue(device.interfaces.filter(name='ge-0/0/0').exists())
+
+ def test_device_creation_duplicate_name_conflict(self):
+ # With conflict
+ device_type = DeviceType.objects.first()
+ # to generate conflicts create an interface that will exist
+ InterfaceTemplate.objects.create(
+ device_type=device_type,
+ name='ge-0/0/0',
+ type='1000base-t',
+ )
+ form = DeviceForm(data={
+ 'name': 'Device VC Form 3',
+ 'role': DeviceRole.objects.first().pk,
+ 'tenant': None,
+ 'manufacturer': Manufacturer.objects.first().pk,
+ 'device_type': device_type.pk,
+ 'site': Site.objects.first().pk,
+ 'rack': None,
+ 'face': None,
+ 'position': None,
+ 'platform': None,
+ 'status': DeviceStatusChoices.STATUS_ACTIVE,
+ })
+ self.assertTrue(form.is_valid())
+ with self.assertRaises(AbortRequest):
+ form.save()
+
+
class FrontPortTestCase(TestCase):
@classmethod
diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py
index 45e07f257..329aa0785 100644
--- a/netbox/dcim/tests/test_models.py
+++ b/netbox/dcim/tests/test_models.py
@@ -1373,6 +1373,167 @@ class VirtualChassisTestCase(TestCase):
device2.full_clean()
+class VCPositionTokenTestCase(TestCase):
+
+ @classmethod
+ def setUpTestData(cls):
+ Site.objects.create(name='Test Site 1', slug='test-site-1')
+ manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
+ DeviceType.objects.create(
+ manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
+ )
+ ModuleType.objects.create(
+ manufacturer=manufacturer, model='Test Module Type 1'
+ )
+ DeviceRole.objects.create(name='Test Role 1', slug='test-role-1')
+
+ def test_vc_position_token_in_vc(self):
+ site = Site.objects.first()
+ device_type = DeviceType.objects.first()
+ module_type = ModuleType.objects.first()
+ device_role = DeviceRole.objects.first()
+
+ InterfaceTemplate.objects.create(
+ module_type=module_type,
+ name='ge-{vc_position}/{module}/0',
+ type='1000base-t',
+ )
+ vc = VirtualChassis.objects.create(name='Test VC 1')
+ device = Device.objects.create(
+ name='Device VC 1', device_type=device_type, role=device_role,
+ site=site, virtual_chassis=vc, vc_position=8,
+ )
+ module_bay = ModuleBay.objects.create(device=device, name='Bay 1', position='1')
+ Module.objects.create(device=device, module_bay=module_bay, module_type=module_type)
+
+ interface = device.interfaces.get(name='ge-8/1/0')
+ self.assertEqual(interface.name, 'ge-8/1/0')
+
+ def test_vc_position_token_not_in_vc_default_fallback(self):
+ site = Site.objects.first()
+ device_type = DeviceType.objects.first()
+ module_type = ModuleType.objects.first()
+ device_role = DeviceRole.objects.first()
+
+ InterfaceTemplate.objects.create(
+ module_type=module_type,
+ name='ge-{vc_position}/{module}/0',
+ type='1000base-t',
+ )
+ device = Device.objects.create(
+ name='Device NoVC 1', device_type=device_type, role=device_role,
+ site=site,
+ )
+ module_bay = ModuleBay.objects.create(device=device, name='Bay 1', position='1')
+ Module.objects.create(device=device, module_bay=module_bay, module_type=module_type)
+
+ interface = device.interfaces.get(name='ge-0/1/0')
+ self.assertEqual(interface.name, 'ge-0/1/0')
+
+ def test_vc_position_token_explicit_fallback(self):
+ site = Site.objects.first()
+ device_type = DeviceType.objects.first()
+ module_type = ModuleType.objects.first()
+ device_role = DeviceRole.objects.first()
+
+ InterfaceTemplate.objects.create(
+ module_type=module_type,
+ name='ge-{vc_position:18}/{module}/0',
+ type='1000base-t',
+ )
+ device = Device.objects.create(
+ name='Device NoVC 2', device_type=device_type, role=device_role,
+ site=site,
+ )
+ module_bay = ModuleBay.objects.create(device=device, name='Bay 1', position='1')
+ Module.objects.create(device=device, module_bay=module_bay, module_type=module_type)
+
+ interface = device.interfaces.get(name='ge-18/1/0')
+ self.assertEqual(interface.name, 'ge-18/1/0')
+
+ def test_vc_position_token_explicit_fallback_ignored_when_in_vc(self):
+ site = Site.objects.first()
+ device_type = DeviceType.objects.first()
+ module_type = ModuleType.objects.first()
+ device_role = DeviceRole.objects.first()
+
+ InterfaceTemplate.objects.create(
+ module_type=module_type,
+ name='ge-{vc_position:99}/{module}/0',
+ type='1000base-t',
+ )
+ vc = VirtualChassis.objects.create(name='Test VC 2')
+ device = Device.objects.create(
+ name='Device VC 2', device_type=device_type, role=device_role,
+ site=site, virtual_chassis=vc, vc_position=2,
+ )
+ module_bay = ModuleBay.objects.create(device=device, name='Bay 1', position='1')
+ Module.objects.create(device=device, module_bay=module_bay, module_type=module_type)
+
+ interface = device.interfaces.get(name='ge-2/1/0')
+ self.assertEqual(interface.name, 'ge-2/1/0')
+
+ def test_vc_position_token_device_type_template(self):
+ site = Site.objects.first()
+ device_type = DeviceType.objects.first()
+ device_role = DeviceRole.objects.first()
+
+ InterfaceTemplate.objects.create(
+ device_type=device_type,
+ name='ge-{vc_position:0}/0/0',
+ type='1000base-t',
+ )
+ vc = VirtualChassis.objects.create(name='Test VC 3')
+ device = Device.objects.create(
+ name='Device VC 3', device_type=device_type, role=device_role,
+ site=site, virtual_chassis=vc, vc_position=3,
+ )
+
+ interface = device.interfaces.get(name='ge-3/0/0')
+ self.assertEqual(interface.name, 'ge-3/0/0')
+
+ def test_vc_position_token_device_type_template_not_in_vc(self):
+ site = Site.objects.first()
+ device_type = DeviceType.objects.first()
+ device_role = DeviceRole.objects.first()
+
+ InterfaceTemplate.objects.create(
+ device_type=device_type,
+ name='ge-{vc_position:0}/0/0',
+ type='1000base-t',
+ )
+ device = Device.objects.create(
+ name='Device NoVC 3', device_type=device_type, role=device_role,
+ site=site,
+ )
+
+ interface = device.interfaces.get(name='ge-0/0/0')
+ self.assertEqual(interface.name, 'ge-0/0/0')
+
+ def test_vc_position_token_label_resolution(self):
+ site = Site.objects.first()
+ device_type = DeviceType.objects.first()
+ module_type = ModuleType.objects.first()
+ device_role = DeviceRole.objects.first()
+
+ InterfaceTemplate.objects.create(
+ module_type=module_type,
+ name='ge-{vc_position}/{module}/0',
+ label='Member {vc_position:0} / Slot {module}',
+ type='1000base-t',
+ )
+ vc = VirtualChassis.objects.create(name='Test VC 4')
+ device = Device.objects.create(
+ name='Device VC 4', device_type=device_type, role=device_role,
+ site=site, virtual_chassis=vc, vc_position=2,
+ )
+ module_bay = ModuleBay.objects.create(device=device, name='Bay 1', position='1')
+ Module.objects.create(device=device, module_bay=module_bay, module_type=module_type)
+
+ interface = device.interfaces.get(name='ge-2/1/0')
+ self.assertEqual(interface.label, 'Member 2 / Slot 1')
+
+
class SiteSignalTestCase(TestCase):
@tag('regression')