first commit
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

This commit is contained in:
Vlastislav Svatek
2026-06-05 10:39:05 +02:00
commit 673e67106e
217 changed files with 76612 additions and 0 deletions

View File

@@ -0,0 +1,640 @@
"""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