"""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