first commit
This commit is contained in:
640
netbox_librenms_plugin/import_utils/virtual_chassis.py
Normal file
640
netbox_librenms_plugin/import_utils/virtual_chassis.py
Normal 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
|
||||
Reference in New Issue
Block a user