Files
netbox-librenms-plugin/netbox_librenms_plugin/import_utils/virtual_chassis.py
Vlastislav Svatek 673e67106e
Some checks failed
ci / deploy (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
first commit
2026-06-05 10:39:05 +02:00

641 lines
25 KiB
Python

"""Virtual chassis detection, creation, and management."""
import logging
from typing import List
from dcim.models import Device, VirtualChassis
from django.core.cache import cache
from django.db import transaction
from ..librenms_api import LibreNMSAPI
logger = logging.getLogger(__name__)
def empty_virtual_chassis_data() -> dict:
"""Public helper for callers that need a blank VC payload."""
return {
"is_stack": False,
"member_count": 0,
"members": [],
"detection_error": None,
}
def _clone_virtual_chassis_data(data: dict | None) -> dict:
"""Return a defensive copy of cached VC data to avoid shared references."""
if not data:
return empty_virtual_chassis_data()
members = []
for idx, member in enumerate(data.get("members", [])):
member_copy = member.copy()
raw_position = member_copy.get("position", idx + 1)
try:
pos = int(raw_position)
member_copy["position"] = pos if pos > 0 else idx + 1
except (TypeError, ValueError):
member_copy["position"] = idx + 1 # 1-based fallback; position 0 is invalid
members.append(member_copy)
member_count = data.get("member_count") or len(members)
return {
"is_stack": bool(data.get("is_stack")),
"member_count": member_count,
"members": members,
"detection_error": data.get("detection_error"),
}
_VC_CACHE_VERSION = "v1"
def _vc_cache_key(api: LibreNMSAPI, device_id: int | str) -> str:
server_key = getattr(api, "server_key", "default")
return f"librenms_vc_detection_{_VC_CACHE_VERSION}_{server_key}_{device_id}"
def get_virtual_chassis_data(api: LibreNMSAPI, device_id: int | str, *, force_refresh: bool = False) -> dict:
"""Fetch (and cache) virtual chassis data for a LibreNMS device."""
if not api or device_id is None:
return empty_virtual_chassis_data()
cache_key = _vc_cache_key(api, device_id)
_cache_timeout = getattr(api, "cache_timeout", None)
cache_timeout = 300 if _cache_timeout is None else _cache_timeout
if not force_refresh and cache_timeout != 0:
cached = cache.get(cache_key)
if cached is not None:
return _clone_virtual_chassis_data(cached)
detection_data = detect_virtual_chassis_from_inventory(api, device_id)
if detection_data is None:
# Non-stack device or transient API failure — cache the negative result so
# prefetch_vc_data_for_devices() can skip these on subsequent renders.
# Use force_refresh=True to bypass the cache if needed.
empty = empty_virtual_chassis_data()
if cache_timeout != 0:
cache.set(cache_key, empty, timeout=cache_timeout)
return _clone_virtual_chassis_data(empty)
if "detection_error" not in detection_data:
detection_data["detection_error"] = None
cache_value = _clone_virtual_chassis_data(detection_data)
if cache_timeout != 0:
cache.set(cache_key, cache_value, timeout=cache_timeout)
return _clone_virtual_chassis_data(cache_value)
def prefetch_vc_data_for_devices(api: LibreNMSAPI, device_ids: List[int], *, force_refresh: bool = False) -> None:
"""
Pre-warm the virtual chassis cache for multiple devices.
This eliminates the 0.5-1s delay when rendering the import table
by proactively fetching VC data before validation.
Args:
api: LibreNMSAPI instance
device_ids: List of LibreNMS device IDs to prefetch VC data for
force_refresh: When True, bypass cache and fetch fresh data
Example:
>>> # Before rendering import table
>>> prefetch_vc_data_for_devices(api, [123, 124, 125])
>>> # Now all validate_device_for_import() calls hit cache instantly
"""
if not api or not device_ids:
return
logger.debug(f"Pre-warming VC cache for {len(device_ids)} devices")
for idx, device_id in enumerate(device_ids):
# This populates the cache if empty, or skips if already cached
try:
get_virtual_chassis_data(api, device_id, force_refresh=force_refresh)
except (BrokenPipeError, ConnectionError, IOError, OSError) as e:
logger.warning(f"Connection error during VC prefetch at device {idx}: {e}")
# Stop processing if connection is broken
return
except Exception as e:
# Log but continue for other errors
logger.warning(f"Error prefetching VC data for device {device_id}: {e}")
logger.debug(f"VC cache warming complete for {len(device_ids)} devices")
def detect_virtual_chassis_from_inventory(api: LibreNMSAPI, device_id: int) -> dict | None:
"""
Detect if device is a stack/Virtual Chassis by analyzing ENTITY-MIB inventory.
Vendor-agnostic using standard hierarchical structure.
Args:
api: LibreNMSAPI instance
device_id: LibreNMS device ID
Returns:
dict with structure:
{
'is_stack': bool,
'member_count': int,
'members': [
{
'serial': str,
'position': int,
'model': str,
'name': str,
'index': int,
'description': str,
'suggested_name': str # Generated using master device name
}
]
}
Returns None if not a stack or detection fails.
Detection Logic:
1. Check root level (entPhysicalContainedIn=0) for parent container
2. Find parent index (entPhysicalClass='stack' or 'chassis')
3. Get children chassis at that parent's index
4. If multiple chassis found -> Stack detected
"""
try:
# Get the master device info to use for naming
success, device_info = api.get_device_info(device_id)
master_name = None
if success and device_info:
master_name = device_info.get("sysName") or device_info.get("hostname")
# Step 1: Get root level items
success, root_items = api.get_inventory_filtered(device_id, ent_physical_contained_in=0)
if not success or not root_items:
logger.debug(f"No root inventory items found for device {device_id}")
return None
# Step 2: Find parent container index
# Prefer "stack" over "chassis" for deterministic VC detection
parent_index = None
stack_index = None
chassis_index = None
for item in root_items:
item_class = item.get("entPhysicalClass")
if item_class == "stack" and stack_index is None:
stack_index = item.get("entPhysicalIndex")
elif item_class == "chassis" and chassis_index is None:
chassis_index = item.get("entPhysicalIndex")
parent_index = stack_index if stack_index is not None else chassis_index
if parent_index is not None:
logger.debug(f"VC detection: Found parent container at index {parent_index} for device {device_id}")
if parent_index is None:
return None
# Step 3: Get children chassis at next level
success, child_items = api.get_inventory_filtered(
device_id,
ent_physical_class="chassis",
ent_physical_contained_in=parent_index,
)
if not success:
return None
# Filter for chassis only (in case API filter didn't work)
chassis_items = [item for item in (child_items or []) if item.get("entPhysicalClass") == "chassis"]
# Step 4: Multiple chassis = stack
if len(chassis_items) <= 1:
return None
# Step 5: Extract member info
# First pass: collect raw entPhysicalParentRelPos values to detect 0-based
# indexing. Some vendors use 0-based positions (0,1,2,3,4) instead of the
# RFC 2737 standard 1-based (1,2,3,4,5). If any raw position is 0, shift
# all valid positions up by 1 so the resulting set is always 1-based.
raw_positions = []
for chassis in chassis_items:
raw = chassis.get("entPhysicalParentRelPos")
try:
raw_positions.append(int(raw))
except (TypeError, ValueError):
raw_positions.append(None)
valid_positions = [p for p in raw_positions if p is not None]
zero_based = bool(valid_positions) and min(valid_positions) == 0
# Identify the master member by matching the LibreNMS device serial
# against the ENTITY-MIB serials. The device-level serial reported by
# LibreNMS corresponds to the active/master switch in the stack.
device_serial = ""
if device_info:
device_serial = _norm_serial(device_info.get("serial"))
# Load naming pattern once to avoid a DB query per member.
vc_name_pattern = _load_vc_member_name_pattern() if master_name else None
members = []
for idx, chassis in enumerate(chassis_items):
raw_pos = raw_positions[idx]
if raw_pos is not None:
position = raw_pos + 1 if zero_based else raw_pos
# Guard against negative or zero after shift
if position <= 0:
position = idx + 1
else:
position = idx + 1
serial = chassis.get("entPhysicalSerialNum", "")
is_master = bool(device_serial and _norm_serial(serial) == device_serial)
member_data = {
"serial": serial,
"position": position,
"model": chassis.get("entPhysicalModelName", ""),
"name": chassis.get("entPhysicalName", ""),
"index": chassis.get("entPhysicalIndex"),
"description": chassis.get("entPhysicalDescr", ""),
"is_master": is_master,
}
# Generate suggested name if we have master name.
# position is already 1-based, so pass it directly (no +1).
if master_name:
member_data["suggested_name"] = _generate_vc_member_name(
master_name, position, serial=_norm_serial(serial), pattern=vc_name_pattern
)
else:
member_data["suggested_name"] = f"Member-{position}"
members.append(member_data)
# Sort by position
members.sort(key=lambda m: m["position"])
if zero_based:
logger.debug(
f"VC detection: corrected 0-based entPhysicalParentRelPos for device {device_id} "
f"(raw min={min(valid_positions)})"
)
master_member = next((m for m in members if m["is_master"]), None)
if master_member:
logger.info(
f"Detected stack with {len(members)} members for device {device_id}; "
f"master at position {master_member['position']} (serial {device_serial})"
)
else:
logger.info(
f"Detected stack with {len(members)} members for device {device_id}; "
f"master could not be identified by serial"
)
return {"is_stack": True, "member_count": len(members), "members": members}
except Exception as e:
logger.exception(f"Error detecting virtual chassis for device {device_id}: {e}")
return None
def _load_vc_member_name_pattern() -> str:
"""Load the VC member name pattern from settings, with fallback to default."""
from ..models import LibreNMSSettings
default = "-M{position}"
try:
settings = LibreNMSSettings.objects.order_by("pk").first()
if not settings:
return default
pattern = settings.vc_member_name_pattern
return pattern if isinstance(pattern, str) and pattern.strip() else default
except Exception as e:
logger.warning(f"Could not load VC member name pattern from settings: {e}. Using default.")
return default
def _generate_vc_member_name(master_name: str, position: int, serial: str = None, pattern: str = None) -> str:
"""
Generate name for VC member device using configured pattern from settings.
Args:
master_name: Name of the master/primary device
position: VC position number
serial: Optional serial number of the member device
pattern: Optional pre-loaded name pattern; if None, loaded from settings.
Pass a pre-loaded pattern when calling inside a loop to avoid
repeated DB queries.
Returns:
Generated member device name
Examples:
pattern="-M{position}" -> "switch01-M2"
pattern=" ({position})" -> "switch01 (2)"
pattern="-SW{position}" -> "switch01-SW2"
pattern=" [{serial}]" -> "switch01 [ABC123]"
"""
if pattern is None:
pattern = _load_vc_member_name_pattern()
# Prepare format variables
format_vars = {
"master_name": master_name,
"position": position,
"serial": serial or "",
}
# Apply pattern - pattern should be suffix/prefix, not full name
try:
formatted_suffix = pattern.format(**format_vars)
return f"{master_name}{formatted_suffix}"
except (KeyError, ValueError, IndexError) as e:
logger.error(f"Invalid placeholder in VC naming pattern '{pattern}': {e}. Using default.")
return f"{master_name}-M{position}"
def update_vc_member_suggested_names(vc_data: dict, master_name: str) -> dict:
"""
Regenerate suggested VC member names using the actual master device name.
This ensures preview shows accurate names after use_sysname and strip_domain
are applied to the master device name.
Args:
vc_data: Virtual chassis detection data dict
master_name: The actual name that will be used for master device in NetBox
Returns:
Updated vc_data dict with corrected suggested_name for each member
"""
if not vc_data or not vc_data.get("is_stack"):
return vc_data
# Load naming pattern once to avoid a DB query per member
vc_pattern = _load_vc_member_name_pattern()
for idx, member in enumerate(vc_data.get("members", [])):
# Positions are stored as 1-based (from entPhysicalParentRelPos or idx+1 fallback).
# Use them directly for name generation; only replace 0/negative with 1-based fallback.
raw_position = member.get("position", idx + 1)
try:
position = int(raw_position)
if position <= 0:
position = idx + 1
except (TypeError, ValueError):
position = idx + 1
member["position"] = position
member["suggested_name"] = _generate_vc_member_name(
master_name, position, serial=_norm_serial(member.get("serial")), pattern=vc_pattern
)
return vc_data
def _safe_pos(value) -> int | None:
"""Return int position or None if not parseable."""
try:
return int(value)
except (TypeError, ValueError):
return None
def _norm_serial(s) -> str:
"""Normalize serial: strip whitespace; treat '-' as absent."""
s = str(s or "").strip()
return "" if s == "-" else s
def _sync_module_bay_counter(device: Device) -> None:
"""Reconcile device module_bay_count with actual ModuleBay rows in the DB."""
try:
actual_count = device.modulebays.count()
if getattr(device, "module_bay_count", None) != actual_count:
Device.objects.filter(pk=device.pk).update(module_bay_count=actual_count)
device.module_bay_count = actual_count
except Exception as e:
logger.warning(
"Could not sync module_bay_count for device '%s': %s",
getattr(device, "name", "unknown"),
e,
)
def create_virtual_chassis_with_members(
master_device: Device, members_info: list, libre_device: dict, server_key: str | None = None
) -> VirtualChassis:
"""
Create Virtual Chassis and member devices from detection info.
This function creates a NetBox VirtualChassis with the master device
and all detected member devices, wrapped in a transaction for safety.
Args:
master_device: The imported device (becomes VC master)
members_info: List of member dicts from VC detection
libre_device: Original LibreNMS device data
Returns:
VirtualChassis: The created virtual chassis instance
Raises:
ValidationError: If member count validation fails
IntegrityError: If duplicate serials/names are detected
Exception: For other creation errors
Example members_info:
[
{'serial': 'ABC123', 'position': 0, 'model': 'C9300-48U', 'name': 'Switch 1'},
{'serial': 'ABC124', 'position': 1, 'model': 'C9300-48U', 'name': 'Switch 2'}
]
"""
# Save originals for in-memory rollback — transaction.atomic() rolls back DB but
# not in-memory model fields.
original_master_name = master_device.name
original_vc = master_device.virtual_chassis
original_vc_position = master_device.vc_position
# Find master's actual VC position from members_info.
# Priority: is_master flag (set during detection) → serial match → default 1.
_master_pos = 1
_master_member = next((m for m in members_info if m.get("is_master")), None)
if _master_member:
_found_pos = _safe_pos(_master_member.get("position"))
if _found_pos and _found_pos >= 1:
_master_pos = _found_pos
elif _norm_serial(master_device.serial):
for _m in members_info:
if _norm_serial(_m.get("serial")) == _norm_serial(master_device.serial):
_found_pos = _safe_pos(_m.get("position"))
if _found_pos and _found_pos >= 1:
_master_pos = _found_pos
break
try:
with transaction.atomic():
# Load naming pattern once to avoid a DB query per member
vc_pattern = _load_vc_member_name_pattern()
# Rename master device to include position 1 pattern
master_device_new_name = _generate_vc_member_name(
original_master_name, _master_pos, serial=_norm_serial(master_device.serial), pattern=vc_pattern
)
# Check if renamed master conflicts with existing device
if Device.objects.filter(name=master_device_new_name).exclude(pk=master_device.pk).exists():
logger.warning(
f"Cannot rename master to '{master_device_new_name}' - name already exists. "
f"Keeping original name '{original_master_name}'"
)
master_base_name = original_master_name
rename_master = False
else:
master_device.name = master_device_new_name
master_base_name = original_master_name
rename_master = True
# Create VC using original base name
vc_name = master_base_name
_device_id = libre_device.get("device_id") or master_device.pk
_domain_prefix = f"librenms-{server_key}" if server_key else "librenms"
vc = VirtualChassis.objects.create(
name=vc_name,
domain=f"{_domain_prefix}-{_device_id}",
)
# Update master device
master_device.virtual_chassis = vc
master_device.vc_position = _master_pos
save_fields = ["virtual_chassis", "vc_position"]
if rename_master:
save_fields.append("name")
master_device.save(update_fields=save_fields)
# Create member devices for remaining positions
position = _master_pos + 1 # Start after master position
used_positions = {_master_pos} # Master occupies its actual position
members_created = 0
for member in members_info:
# Normalize serial and position up front so all skip-checks and
# downstream logic use consistent values (strips whitespace and
# treats the sentinel "-" as "no serial").
serial = str(member.get("serial") or "").strip()
if serial == "-":
serial = ""
member_pos = _safe_pos(member.get("position"))
# Skip the master member — identified by is_master flag, serial match,
# or position match.
if member.get("is_master"):
continue
# Skip if this is the master's serial (only when both serials are non-empty)
if serial and serial == _norm_serial(master_device.serial):
continue
# Skip blank-serial entries that represent the master slot by position
if (
not serial
and member_pos is not None
and master_device.vc_position is not None
and member_pos == master_device.vc_position
):
continue
member_rack = master_device.rack
member_location = master_device.location or (
member_rack.location if member_rack and member_rack.location else None
)
# Check for duplicate serial
if serial and Device.objects.filter(serial=serial).exists():
logger.warning(f"Device with serial '{serial}' already exists, skipping VC member creation")
continue
# Prefer the discovered SNMP position; fall back to sequential counter.
# member_pos was normalized via _safe_pos() above; 0 is not a valid vc_position.
discovered_pos = member_pos if (member_pos is not None and member_pos >= 1) else None
# If discovered_pos is already taken by another member, treat as absent.
if discovered_pos is not None and discovered_pos in used_positions:
discovered_pos = None
# Consume next free sequential slot when no valid discovered_pos.
if discovered_pos is None:
while position in used_positions:
position += 1
chosen_pos = position
position += 1
else:
chosen_pos = discovered_pos
# Advance sequential counter past chosen position.
position = max(position, chosen_pos + 1)
used_positions.add(chosen_pos)
member_name = _generate_vc_member_name(master_base_name, chosen_pos, serial=serial, pattern=vc_pattern)
# Check for duplicate name
if Device.objects.filter(name=member_name).exists():
logger.warning(f"Device with name '{member_name}' already exists, skipping VC member creation")
continue
Device.objects.create(
name=member_name,
device_type=master_device.device_type,
role=master_device.role,
site=master_device.site,
location=member_location,
rack=member_rack,
platform=master_device.platform,
serial=serial,
virtual_chassis=vc,
vc_position=chosen_pos,
comments=f"VC member (LibreNMS: {member.get('name', 'Unknown')})\n"
f"Auto-created from stack inventory",
)
members_created += 1
# Validate member count
# Validate member count — exclude master-slot entries with blank serials
expected_members = len(
[
m
for m in members_info
if not (
_norm_serial(m.get("serial"))
and _norm_serial(m.get("serial")) == _norm_serial(master_device.serial)
)
and not (
not _norm_serial(m.get("serial"))
and m.get("position") is not None
and master_device.vc_position is not None
and _safe_pos(m["position"]) == master_device.vc_position
)
]
)
if members_created < expected_members:
logger.warning(
f"Created {members_created} members but expected {expected_members}. "
"Some members may have been skipped due to duplicates."
)
# Assign VC master only after all members are attached to avoid
# NetBox's create-time auto-master signal changing order/state.
vc.master = master_device
vc.save(update_fields=["master"])
_sync_module_bay_counter(master_device)
logger.info(
f"Created Virtual Chassis '{vc.name}' with {vc.members.count()} total members "
f"(1 master + {members_created} additional)"
)
return vc
except Exception as e:
master_device.name = original_master_name
master_device.virtual_chassis = original_vc
master_device.vc_position = original_vc_position
logger.error(
f"Virtual Chassis creation failed for device {original_master_name}: {e}",
exc_info=True,
)
raise