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,53 @@
"""
Utilities for importing devices from LibreNMS to NetBox.
This package provides functions for:
- Validating LibreNMS devices for import
- Retrieving filtered LibreNMS devices
- Importing single and multiple devices
- Smart matching of NetBox objects
- Permission checking for import operations
- Virtual chassis detection and creation
All imports below are intentional re-exports so that existing callers
can continue using ``from netbox_librenms_plugin.import_utils import X``.
The F401 suppressions prevent linters from flagging them as unused.
"""
from .bulk_import import ( # noqa: F401
bulk_import_devices,
bulk_import_devices_shared,
process_device_filters,
)
from .cache import ( # noqa: F401
get_active_cached_searches,
get_cache_metadata_key,
get_import_device_cache_key,
get_import_search_cache_key,
get_validated_device_cache_key,
)
from .device_operations import ( # noqa: F401
_determine_device_name,
fetch_device_with_cache,
get_librenms_device_by_id,
import_single_device,
validate_device_for_import,
)
from .filters import ( # noqa: F401
_apply_client_filters,
get_device_count_for_filters,
get_librenms_devices_for_import,
)
from .permissions import check_user_permissions, require_permissions # noqa: F401
from .virtual_chassis import ( # noqa: F401
_clone_virtual_chassis_data,
_generate_vc_member_name,
_vc_cache_key,
create_virtual_chassis_with_members,
detect_virtual_chassis_from_inventory,
empty_virtual_chassis_data,
get_virtual_chassis_data,
prefetch_vc_data_for_devices,
update_vc_member_suggested_names,
)
from .vm_operations import bulk_import_vms, create_vm_from_librenms # noqa: F401

View File

@@ -0,0 +1,706 @@
"""Bulk import orchestration for devices and filter processing."""
import hashlib
import logging
from typing import List
from django.core.cache import cache
from ..import_validation_helpers import apply_role_to_validation, recalculate_validation_status, remove_validation_issue
from ..librenms_api import LibreNMSAPI
from ..utils import find_by_librenms_id
from .cache import get_cache_metadata_key, get_import_device_cache_key, get_validated_device_cache_key
from .device_operations import import_single_device, validate_device_for_import
from .filters import _safe_disabled, get_librenms_devices_for_import
from .permissions import check_user_permissions, require_permissions
from .virtual_chassis import (
create_virtual_chassis_with_members,
empty_virtual_chassis_data,
prefetch_vc_data_for_devices,
)
logger = logging.getLogger(__name__)
def _is_job_cancelled(job) -> bool:
"""
Return True if a background job has been stopped or cancelled.
Checks RQ/Redis state only (reflects stop API calls immediately).
On Redis connectivity issues or a missing RQ job, returns False to avoid
false cancellation. Unexpected exceptions are logged and also return False.
"""
from django_rq import get_queue
from redis.exceptions import RedisError
from rq.exceptions import NoSuchJobError
from rq.job import Job as RQJob
try:
queue = get_queue("default")
rq_job = RQJob.fetch(str(job.job.job_id), connection=queue.connection)
return rq_job.is_failed or rq_job.is_stopped
except (RedisError, NoSuchJobError):
return False
except Exception:
logger.warning("Unexpected error checking RQ job cancellation state", exc_info=True)
return False
def bulk_import_devices_shared(
device_ids: List[int],
server_key: str = None,
sync_options: dict = None,
manual_mappings_per_device: dict = None,
libre_devices_cache: dict = None,
job=None,
user=None,
) -> dict:
"""
Shared function for importing multiple LibreNMS devices to NetBox.
Used by both synchronous imports and background jobs. Handles per-device error
collection and optional progress logging when job context is provided.
Args:
device_ids: List of LibreNMS device IDs to import
server_key: LibreNMS server configuration key
sync_options: Sync options to apply to all devices
manual_mappings_per_device: Dict mapping device_id to manual_mappings dict
Example: {1179: {'device_role_id': 5}, 1180: {'device_role_id': 3}}
libre_devices_cache: Optional dict mapping device_id to pre-fetched device data
to avoid redundant API calls. Example: {123: {...device_data...}}
job: Optional JobRunner instance for progress logging and cancellation checks
user: User performing the import (for permission checks). If job is provided,
user is extracted from job.job.user if not explicitly passed.
Returns:
dict: Bulk import result with structure:
{
'total': int,
'success': List[dict], # Successfully imported devices
'failed': List[dict], # Failed imports with errors
'skipped': List[dict], # Skipped devices (already exist, etc.)
'virtual_chassis_created': int # Number of VCs created
}
Raises:
PermissionDenied: If user lacks required permissions
Example:
>>> # Synchronous usage
>>> result = bulk_import_devices_shared([1, 2, 3, 4, 5], user=request.user)
>>> # Background job usage
>>> result = bulk_import_devices_shared([1, 2, 3], job=self)
"""
# Extract user from job if not explicitly provided
if user is None and job is not None:
user = getattr(job.job, "user", None)
# Check permissions at start of bulk operation — device and VM add perms are
# required because any device may be flagged as import_as_vm during validation.
# change_device is needed for VC master/member updates.
required_perms = [
"dcim.add_device",
"dcim.change_device",
"virtualization.add_virtualmachine",
]
require_permissions(user, required_perms, "import devices")
total = len(device_ids)
success_list = []
failed_list = []
skipped_list = []
vc_created_count = 0
processed_vc_domains = set() # Track VCs already created by domain
_cancelled = False
# Initialize API client once for all devices to avoid repeated config parsing
api = LibreNMSAPI(server_key=server_key)
for idx, device_id in enumerate(device_ids, start=1):
# Check for job cancellation on first iteration and every 5th thereafter.
if job and (idx == 1 or idx % 5 == 0) and _is_job_cancelled(job):
if job.logger:
job.logger.warning(f"Import job stopped at device {idx} of {total}")
else:
logger.warning(f"Import cancelled at device {idx} of {total}")
_cancelled = True
break
try:
# Use cached device data if available to avoid redundant API calls
if libre_devices_cache and device_id in libre_devices_cache:
libre_device = libre_devices_cache[device_id]
success = True
else:
success, libre_device = api.get_device_info(device_id)
if not success or not libre_device:
error_msg = f"Failed to retrieve device {device_id} from LibreNMS"
failed_list.append({"device_id": device_id, "error": error_msg})
if job and job.logger:
job.logger.error(error_msg)
else:
logger.error(error_msg)
continue
use_sysname_opt = sync_options.get("use_sysname", True) if sync_options else True
strip_domain_opt = sync_options.get("strip_domain", False) if sync_options else False
validation = validate_device_for_import(
libre_device,
api=api,
use_sysname=use_sysname_opt,
strip_domain=strip_domain_opt,
server_key=api.server_key,
# Import-time behavior: always evaluate VC state from live/cached
# LibreNMS inventory so stack members are created even when preview
# flags are stale or omitted.
include_vc_detection=True,
)
vc_data = validation.get("virtual_chassis", {})
if vc_data.get("is_stack", False):
has_vc_perm, _ = check_user_permissions(user, ["dcim.add_virtualchassis"])
if not has_vc_perm:
error_msg = f"Cannot import stack device {device_id}: missing permission dcim.add_virtualchassis"
failed_list.append({"device_id": device_id, "error": error_msg})
if job and job.logger:
job.logger.error(error_msg)
else:
logger.error(error_msg)
continue
# Build manual mappings from validation + any provided overrides
device_mappings = {}
# Get site and device_type from validation
if validation["site"].get("found") and validation["site"].get("site"):
device_mappings["site_id"] = validation["site"]["site"].id
if validation["device_type"].get("found") and validation["device_type"].get("device_type"):
device_mappings["device_type_id"] = validation["device_type"]["device_type"].id
if validation["platform"].get("found") and validation["platform"].get("platform"):
device_mappings["platform_id"] = validation["platform"]["platform"].id
# Override with any manual mappings provided for this device
if manual_mappings_per_device and device_id in manual_mappings_per_device:
device_mappings.update(manual_mappings_per_device[device_id])
result = import_single_device(
device_id,
server_key=api.server_key, # use resolved key, not raw parameter (may be None)
validation=validation,
sync_options=sync_options,
manual_mappings=device_mappings if device_mappings else None,
libre_device=libre_device,
)
if result["success"]:
success_list.append(
{
"device_id": device_id,
"device": result["device"],
"message": result["message"],
}
)
# Log progress after each successful import
if job and job.logger:
job.logger.info(f"Imported device {idx} of {total}")
# Handle virtual chassis creation for stacks
if vc_data.get("is_stack", False):
# Derive a stack-level dedup key from member serials so that all
# LibreNMS devices belonging to the same physical stack (e.g. each
# switch in a stacked chassis that appears as a separate device in
# LibreNMS) share the same key and VC creation is triggered only once.
# Fall back to device_id when no member serials are available.
member_serials = sorted(
serial
for m in vc_data.get("members", [])
if (serial := str(m.get("serial") or "").strip()) and serial != "-"
)
if member_serials:
vc_domain = f"librenms-stack-{','.join(member_serials)}"
else:
# No serials available — build a stable fingerprint from member name/model/position
# so all LibreNMS devices in the same physical stack share the same dedup key.
member_parts = sorted(
f"{m.get('name', '')}/{m.get('model', '')}:{m.get('position', 0)}"
for m in vc_data.get("members", [])
)
if member_parts:
fingerprint = hashlib.md5(",".join(member_parts).encode()).hexdigest()[:12]
vc_domain = f"librenms-stack-{fingerprint}"
else:
vc_domain = f"librenms-{device_id}"
# Only create VC if we haven't processed this stack yet.
# Permission was already validated before device import.
if vc_domain not in processed_vc_domains:
# Add to set BEFORE attempting creation to prevent race condition
processed_vc_domains.add(vc_domain)
try:
vc = create_virtual_chassis_with_members(
result["device"],
vc_data["members"],
libre_device,
server_key=api.server_key,
)
vc_created_count += 1
log_msg = f"Created VC '{vc.name}' during bulk import for device {device_id}"
if job and job.logger:
job.logger.info(log_msg)
else:
logger.info(log_msg)
except Exception as vc_error:
# Remove from set on failure so retry is possible
processed_vc_domains.discard(vc_domain)
warn_msg = f"Failed to create VC for device {device_id}: {vc_error}"
if job and job.logger:
job.logger.warning(warn_msg)
else:
logger.warning(warn_msg)
# Don't fail the import, just log the warning
elif result.get("device"): # Device exists
skipped_list.append({"device_id": device_id, "reason": result["error"]})
else: # Failed to import
failed_list.append({"device_id": device_id, "error": result["error"]})
if job and job.logger:
job.logger.error(f"Failed to import device {device_id}: {result['error']}")
except Exception as e:
error_msg = f"Unexpected error importing device {device_id}: {str(e)}"
if job and job.logger:
job.logger.error(error_msg, exc_info=True)
else:
logger.exception(f"Unexpected error importing device {device_id}")
failed_list.append({"device_id": device_id, "error": str(e)})
return {
"total": total,
"success": success_list,
"failed": failed_list,
"skipped": skipped_list,
"virtual_chassis_created": vc_created_count,
"cancelled": _cancelled,
}
def bulk_import_devices(
device_ids: List[int],
server_key: str = None,
sync_options: dict = None,
manual_mappings_per_device: dict = None,
libre_devices_cache: dict = None,
user=None,
) -> dict:
"""
Import multiple LibreNMS devices to NetBox (synchronous).
This is the public API for synchronous imports. For background job usage,
use bulk_import_devices_shared() with a job context.
Args:
device_ids: List of LibreNMS device IDs to import
server_key: LibreNMS server configuration key
sync_options: Sync options to apply to all devices
manual_mappings_per_device: Dict mapping device_id to manual_mappings dict
Example: {1179: {'device_role_id': 5}, 1180: {'device_role_id': 3}}
libre_devices_cache: Optional dict mapping device_id to pre-fetched device data
to avoid redundant API calls. Example: {123: {...device_data...}}
user: User performing the import (for permission checks)
Returns:
dict: Bulk import result with structure:
{
'total': int,
'success': List[dict], # Successfully imported devices
'failed': List[dict], # Failed imports with errors
'skipped': List[dict], # Skipped devices (already exist, etc.)
'virtual_chassis_created': int # Number of VCs created
}
Raises:
PermissionDenied: If user lacks required permissions
"""
return bulk_import_devices_shared(
device_ids=device_ids,
server_key=server_key,
sync_options=sync_options,
manual_mappings_per_device=manual_mappings_per_device,
libre_devices_cache=libre_devices_cache,
job=None, # No job context for synchronous imports
user=user,
)
def _refresh_existing_device(validation: dict, libre_device: dict = None, server_key: str = "default") -> None:
"""
Refresh existing_device from DB to pick up changes made in NetBox since caching.
When existing_device is None (wasn't found at cache time), re-check if the device
was imported since caching by looking up librenms_id or hostname.
"""
existing = validation.get("existing_device")
if existing and hasattr(existing, "pk"):
try:
from dcim.models import Device
from virtualization.models import VirtualMachine
if validation.get("import_as_vm"):
refreshed = VirtualMachine.objects.filter(pk=existing.pk).first()
else:
refreshed = Device.objects.filter(pk=existing.pk).first()
if refreshed:
validation["existing_device"] = refreshed
if hasattr(refreshed, "role") and refreshed.role:
apply_role_to_validation(validation, refreshed.role, is_vm=bool(validation.get("import_as_vm")))
elif not validation.get("import_as_vm"):
validation["device_role"] = {"found": False, "role": None}
remove_validation_issue(validation, "role")
recalculate_validation_status(validation, is_vm=bool(validation.get("import_as_vm")))
# Re-assert non-importable state: recalculate bases can_import on
# issues alone, but an existing matched device must never be import-ready.
validation["can_import"] = False
validation["is_ready"] = False
return
else:
# Device was deleted since caching — recompute readiness to match
# validate_device_for_import logic.
validation["existing_device"] = None
validation["existing_match_type"] = None
# Clear stale device_role so is_ready is computed from scratch.
# Guard: VMs don't use device_role for readiness, so preserve any
# user-selected role rather than silently dropping it.
if not validation.get("import_as_vm"):
validation["device_role"] = {"found": False, "role": None}
recalculate_validation_status(validation, is_vm=bool(validation.get("import_as_vm")))
except Exception as e:
existing_id = getattr(existing, "pk", "unknown") if existing else "none"
logger.error(f"Failed to refresh existing device (pk={existing_id}): {e}")
return
# existing_device was None at cache time — check if device was imported since
if not libre_device:
return
try:
from dcim.models import Device
from virtualization.models import VirtualMachine
import_as_vm = validation.get("import_as_vm", False)
Model = VirtualMachine if import_as_vm else Device
# Also check the opposite model — the LibreNMS object may have been
# imported as a VM even though import_as_vm=False (or vice versa).
CrossModel = Device if import_as_vm else VirtualMachine
librenms_id = libre_device.get("device_id")
hostname = libre_device.get("hostname", "")
sys_name = libre_device.get("sysName", "")
new_device = None
match_type = None
found_as_cross_model = False
def _lookup_in_model(m):
"""Return (device, match_type) for model m, or (None, None)."""
if librenms_id is not None and not isinstance(librenms_id, bool):
try:
dev = find_by_librenms_id(m, int(librenms_id), server_key)
if dev:
return dev, "librenms_id"
except (ValueError, TypeError):
pass
resolved_name = validation.get("resolved_name")
if resolved_name:
dev = m.objects.filter(name__iexact=resolved_name).first()
if dev:
return dev, "resolved_name"
if hostname:
dev = m.objects.filter(name__iexact=hostname).first()
if dev:
return dev, "hostname"
if sys_name:
dev = m.objects.filter(name__iexact=sys_name).first()
if dev:
return dev, "sysname"
return None, None
new_device, match_type = _lookup_in_model(Model)
if not new_device:
# Try the opposite model: catches cross-model imports that happened
# after the cache was built (e.g. LibreNMS device imported as VM).
new_device, match_type = _lookup_in_model(CrossModel)
if new_device:
found_as_cross_model = True
if new_device:
validation["existing_device"] = new_device
validation["existing_match_type"] = match_type
validation["can_import"] = False
validation["is_ready"] = False
# Determine actual model from the found object, not from import_as_vm flag
actual_is_vm = found_as_cross_model != import_as_vm # XOR: cross flips the flag
validation["import_as_vm"] = actual_is_vm # Update so future refreshes query correct model
if not actual_is_vm and hasattr(new_device, "role") and new_device.role:
apply_role_to_validation(validation, new_device.role, is_vm=False)
elif not actual_is_vm:
validation["device_role"] = {"found": False, "role": None}
recalculate_validation_status(validation, is_vm=actual_is_vm)
except Exception as e:
logger.error(f"Failed to check for newly imported device: {e}")
def _empty_return(return_cache_status: bool):
"""Centralised empty-result return value for process_device_filters."""
return ([], False) if return_cache_status else []
def process_device_filters(
api: LibreNMSAPI,
filters: dict,
vc_detection_enabled: bool,
clear_cache: bool,
show_disabled: bool,
exclude_existing: bool = False,
job=None,
request=None,
return_cache_status: bool = False,
use_sysname: bool = True,
strip_domain: bool = False,
) -> List[dict] | tuple[List[dict], bool]:
"""
Process LibreNMS device filters and return validated devices.
Shared function used by both synchronous view and background job processing.
Fetches devices, optionally pre-warms VC cache, validates each device, and
caches results for HTMX row updates.
Args:
api: LibreNMS API client instance
filters: Filter dict with location, type, os, hostname, sysname, hardware keys
vc_detection_enabled: Whether to detect virtual chassis
clear_cache: Whether to force cache refresh
show_disabled: Whether to include disabled devices
exclude_existing: Whether to exclude devices that already exist in NetBox
job: Optional JobRunner instance for logging job events
request: Optional Django request for client disconnect detection (synchronous only)
return_cache_status: When True, returns (devices, from_cache) tuple
use_sysname: If True, prefer sysName over hostname for device name resolution
strip_domain: If True, strip domain suffix from device name
Returns:
List[dict]: Validated devices with _validation key, or tuple of (devices, from_cache)
if return_cache_status is True. from_cache=True means data was loaded from existing
cache; from_cache=False means data was just fetched from LibreNMS.
"""
# Fetch devices from LibreNMS
if job:
job.logger.info(f"Fetching devices with filters: {filters}")
if _is_job_cancelled(job):
job.logger.warning("Job was stopped before fetching devices")
return _empty_return(return_cache_status)
else:
logger.info(f"Fetching devices with filters: {filters}")
# Always get cache status internally, even if not returning it
# We need it to determine if metadata should be updated
libre_devices, from_cache = get_librenms_devices_for_import(
api,
filters=filters,
force_refresh=clear_cache,
return_cache_status=True,
)
# Filter out disabled devices if requested. LibreNMS's "disabled" field (1=disabled,
# 0=enabled) reflects manual device disablement; "status" reflects SNMP reachability.
# show_disabled controls the former: hidden when disabled==1, shown regardless of status.
if not show_disabled:
libre_devices = [d for d in libre_devices if _safe_disabled(d) != 1]
if job:
job.logger.info(f"Found {len(libre_devices)} devices to process")
else:
logger.info(f"Found {len(libre_devices)} devices")
# Check for early cancellation before the expensive VC prefetch
if job and _is_job_cancelled(job):
job.logger.warning("Job was stopped before VC pre-fetch")
return _empty_return(return_cache_status)
# Pre-warm VC cache if needed
if vc_detection_enabled and libre_devices:
device_ids = [d["device_id"] for d in libre_devices]
if job:
job.logger.info(
f"Pre-fetching virtual chassis data for {len(device_ids)} devices. This may take some time..."
)
else:
logger.info(f"Pre-fetching VC data for {len(device_ids)} devices")
try:
prefetch_vc_data_for_devices(api, device_ids, force_refresh=clear_cache)
if job:
job.logger.info("Virtual chassis data pre-fetch completed")
except (BrokenPipeError, ConnectionError, IOError) as e:
if request:
logger.info(f"Client disconnected during VC prefetch: {e}")
return _empty_return(return_cache_status)
raise
# Validate each device
validated_devices = []
total = len(libre_devices)
# Always pass api so validate_device_for_import can run hardware/chassis lookups.
# vc_detection_enabled only gates VC-specific paths inside that function.
if job:
job.logger.info(f"Starting validation of {total} devices")
if _is_job_cancelled(job):
job.logger.warning("Job was already stopped before validation started")
return _empty_return(return_cache_status)
else:
logger.info(f"Validating {total} devices")
for idx, device in enumerate(libre_devices, 1):
# Check for job termination periodically
if (idx % 5 == 0 or idx == 1) and job and _is_job_cancelled(job):
job.logger.info(f"Job stopped at device {idx}/{total}. Exiting gracefully.")
return _empty_return(return_cache_status)
# Drop any cached validation/meta keys before recomputing
device.pop("_validation", None)
# Generate shared cache key for this validated device
device_id = device["device_id"]
cache_key = get_validated_device_cache_key(
server_key=api.server_key,
filters=filters,
device_id=device_id,
vc_enabled=vc_detection_enabled,
use_sysname=use_sysname,
strip_domain=strip_domain,
)
# Check if we already have cached validation for this device
# (only if not forcing refresh)
if not clear_cache:
cached_device = cache.get(cache_key)
if cached_device:
# Use cached validation
device["_validation"] = cached_device["_validation"]
# Refresh existing_device from DB to avoid stale data
# (user may have changed role, name, etc. in NetBox)
_refresh_existing_device(device["_validation"], libre_device=device, server_key=api.server_key)
# Apply exclude_existing filter if enabled
if exclude_existing:
validation = device["_validation"]
if validation["existing_device"]:
continue
validated_devices.append(device)
continue
# Not in cache or forcing refresh - validate now
try:
validation = validate_device_for_import(
device,
api=api,
include_vc_detection=vc_detection_enabled,
force_vc_refresh=False,
server_key=api.server_key,
use_sysname=use_sysname,
strip_domain=strip_domain,
)
except (BrokenPipeError, ConnectionError, IOError) as e:
if request:
logger.info(f"Client disconnected during device validation: {e}")
return _empty_return(return_cache_status)
raise
# Set VC detection metadata
if not vc_detection_enabled:
validation["virtual_chassis"] = empty_virtual_chassis_data()
# Apply exclude_existing filter if enabled
if exclude_existing and validation["existing_device"]:
continue
device["_validation"] = validation
validated_devices.append(device)
# Cache with TWO keys for different purposes:
# 1. Complex key (with filter context) - for full validated device with all metadata
cache.set(cache_key, device, timeout=api.cache_timeout)
# 2. Simple key (device ID only) - for quick device data lookup by role/rack updates
# This avoids redundant API calls when user interacts with dropdowns
simple_cache_key = get_import_device_cache_key(device_id, api.server_key)
# Cache just the raw device data (not the full validation result)
# This is what get_validated_device_with_selections() expects
device_data_only = {k: v for k, v in device.items() if k != "_validation"}
cache.set(simple_cache_key, device_data_only, timeout=api.cache_timeout)
# Store cache metadata (timestamp) for all filter operations
# This enables countdown display regardless of background job vs synchronous execution
# Always store metadata when we have validated devices, even if from_cache
# This ensures metadata is available for countdown display
if validated_devices:
from datetime import datetime, timezone
cache_metadata_key = get_cache_metadata_key(
server_key=api.server_key,
filters=filters,
vc_enabled=vc_detection_enabled,
use_sysname=use_sysname,
strip_domain=strip_domain,
)
# Check if metadata already exists to preserve original timestamp
# BUT: if clear_cache was requested or data came fresh from LibreNMS, update it
existing_metadata = cache.get(cache_metadata_key)
should_update = clear_cache or not from_cache
if existing_metadata and not should_update:
# Metadata exists and cache wasn't cleared, keep using it (preserves original cache time)
pass
else:
# No metadata exists, OR cache was cleared, OR fresh data - create/update it now
cache_metadata = {
"cached_at": datetime.now(timezone.utc).isoformat(),
"cache_timeout": api.cache_timeout,
"filters": filters,
"vc_enabled": vc_detection_enabled,
"device_count": len(validated_devices),
}
cache.set(cache_metadata_key, cache_metadata, timeout=api.cache_timeout)
# Maintain cache index for this server to enable listing active searches
cache_index_key = f"librenms_cache_index_{api.server_key}"
cache_index = cache.get(cache_index_key, [])
# Add this cache key if not already in index
if cache_metadata_key not in cache_index:
cache_index.append(cache_metadata_key)
# Always re-write the index so its TTL matches the freshly-written metadata.
# Without this the index can expire before the metadata and the active
# search entry disappears from the UI.
cache.set(cache_index_key, cache_index, timeout=api.cache_timeout)
if job:
if exclude_existing:
filtered_count = total - len(validated_devices)
job.logger.info(
f"Validation complete: {len(validated_devices)} devices passed filter, "
f"{filtered_count} filtered out (existing devices excluded)"
)
else:
job.logger.info(f"Validation complete: {len(validated_devices)} devices ready for import")
else:
logger.info(f"Processed {len(validated_devices)} validated devices")
if return_cache_status:
return validated_devices, from_cache
return validated_devices

View File

@@ -0,0 +1,232 @@
"""Cache key generation and management for device import operations."""
import hashlib
import json
import logging
from django.core.cache import cache
logger = logging.getLogger(__name__)
def _build_filter_hash(filters: dict) -> str:
"""
Build a stable, collision-free hash from a filter dict.
Removes None values (preserves valid falsy values like 0 and False),
sorts by key, and returns the first 16 hex characters of the SHA-256
digest of the JSON-serialized result.
"""
return hashlib.sha256(
json.dumps({k: v for k, v in filters.items() if v is not None}, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()[:16]
def get_location_choices_cache_key(server_key: str) -> str:
"""Return the cache key for LibreNMS location choices for a given server."""
return f"librenms_locations_choices:{server_key}"
def get_cache_metadata_key(
server_key: str, filters: dict, vc_enabled: bool, use_sysname: bool = True, strip_domain: bool = False
) -> str:
"""
Generate a consistent cache metadata key from filter parameters.
Args:
server_key: LibreNMS server identifier
filters: Filter dictionary
vc_enabled: Whether VC detection is enabled
use_sysname: Whether sysName is preferred over hostname for device naming
strip_domain: Whether domain suffix is stripped from device names
Returns:
str: Consistent cache key for metadata
"""
# Sort filter items to ensure consistent key generation; use "is not None" to preserve
# valid falsy values like 0 and False (filtering only None/missing entries).
# Use JSON serialization for a stable, collision-free hash (avoids issues with
# values containing "=" or "_" that could collide with the key separators).
filter_hash = _build_filter_hash(filters)
return f"librenms_filter_cache_metadata_{server_key}_{filter_hash}_{vc_enabled}_sysname={use_sysname}_strip={strip_domain}"
def get_active_cached_searches(server_key: str) -> list[dict]:
"""
Retrieve all active cached searches for a server and enrich with display-friendly values.
Enriches raw filter IDs with human-readable names by looking up location names
from cached choices and converting type codes to display names.
Args:
server_key: LibreNMS server identifier
Returns:
List of dicts containing cache metadata with enriched display_filters
"""
from datetime import datetime, timezone
cache_index_key = f"librenms_cache_index_{server_key}"
cache_index = cache.get(cache_index_key, [])
active_searches = []
valid_cache_keys = []
# Get location and type choices for enriching display
location_choices = {}
type_choices = {
"": "All Types",
"network": "Network",
"server": "Server",
"storage": "Storage",
"wireless": "Wireless",
"firewall": "Firewall",
"power": "Power",
"appliance": "Appliance",
"printer": "Printer",
"loadbalancer": "Load Balancer",
"other": "Other",
}
# Get cached location choices for enrichment; scoped by server_key so labels
# from different LibreNMS servers don't bleed into each other's filter summaries.
location_cache_key = get_location_choices_cache_key(server_key)
cached_locations = cache.get(location_cache_key)
if cached_locations:
location_choices = dict(cached_locations)
for cache_key in cache_index:
metadata = cache.get(cache_key)
if metadata:
# Cache still exists, calculate time remaining
cache_timeout = metadata.get("cache_timeout", 300)
now = datetime.now(timezone.utc)
try:
cached_at_raw = metadata.get("cached_at")
if isinstance(cached_at_raw, datetime):
cached_at = cached_at_raw
elif cached_at_raw:
cached_at = datetime.fromisoformat(cached_at_raw)
else:
cached_at = datetime.fromtimestamp(0, timezone.utc)
# Normalize naive datetimes (e.g., stored without tzinfo) to UTC
if cached_at.tzinfo is None:
cached_at = cached_at.replace(tzinfo=timezone.utc)
except (ValueError, TypeError):
cached_at = datetime.fromtimestamp(0, timezone.utc)
age_seconds = (now - cached_at).total_seconds()
remaining_seconds = max(0, cache_timeout - age_seconds)
if remaining_seconds > 0:
# Add remaining time and cache key
metadata["remaining_seconds"] = int(remaining_seconds)
metadata["cache_key"] = cache_key
# Store numeric sort key so the final sort is unambiguous
metadata["cached_at_ts"] = cached_at.timestamp()
# Enrich filters with human-readable display values
if "filters" in metadata:
display_filters = metadata["filters"].copy()
# Convert location ID to location name
if "location" in display_filters and display_filters["location"] in location_choices:
display_filters["location"] = location_choices[display_filters["location"]]
# Convert type code to display name
if "type" in display_filters and display_filters["type"] in type_choices:
display_filters["type"] = type_choices[display_filters["type"]]
metadata["display_filters"] = display_filters
else:
# Fallback if filters key missing
metadata["display_filters"] = {}
active_searches.append(metadata)
valid_cache_keys.append(cache_key)
# Clean up index if any keys have expired
if len(valid_cache_keys) < len(cache_index):
cache.set(cache_index_key, valid_cache_keys, timeout=3600)
# Sort by most recent first
active_searches.sort(key=lambda x: x.get("cached_at_ts", 0.0), reverse=True)
return active_searches
def get_validated_device_cache_key(
server_key: str,
filters: dict,
device_id: int | str,
vc_enabled: bool,
use_sysname: bool = True,
strip_domain: bool = False,
) -> str:
"""
Generate a consistent cache key for validated device data.
This ensures both synchronous and background job processing use the same
cache keys, avoiding duplicate validation work and cache entries.
Args:
server_key: LibreNMS server key
filters: Filter dict with location, type, os, hostname, sysname, hardware keys
device_id: LibreNMS device ID
vc_enabled: Whether virtual chassis detection was enabled
use_sysname: Whether sysName is preferred over hostname for device naming
strip_domain: Whether domain suffix is stripped from device names
Returns:
str: Cache key for the validated device
Example:
>>> key = get_validated_device_cache_key('default', {'location': 'NYC'}, 123, True)
>>> key
'validated_device_default_e3b0c44298fc1c14_123_vc'
"""
# Sort filters for a deterministic, cross-process stable hash; None values are excluded
# (consistent with get_cache_metadata_key).
filter_hash = _build_filter_hash(filters)
vc_part = "vc" if vc_enabled else "novc"
return (
f"validated_device_{server_key}_{filter_hash}_{device_id}_{vc_part}_sysname={use_sysname}_strip={strip_domain}"
)
def get_import_device_cache_key(device_id: int | str, server_key: str = "default") -> str:
"""
Generate cache key for raw LibreNMS device data.
This key is used to cache raw device data (without validation metadata)
to avoid redundant API calls when users interact with dropdowns during
the import workflow.
Args:
device_id: LibreNMS device ID
server_key: LibreNMS server identifier for multi-server setups. Defaults to "default" for backward compatibility.
Returns:
str: Cache key for the device data
Example:
>>> get_import_device_cache_key(123, "production")
'import_device_data_production_123'
"""
return f"import_device_data_{server_key}_{device_id}"
def get_import_search_cache_key(server_key: str, api_filters: dict, client_filters: dict) -> str:
"""
Generate a deterministic cache key for a LibreNMS device search result.
The key encodes the server, API-side filters, and client-side filters so
that different filter combinations produce distinct cache entries.
Args:
server_key: Resolved LibreNMS server key (use ``api.server_key``).
api_filters: Filters forwarded to the LibreNMS API.
client_filters: Filters applied client-side after the API response.
Returns:
str: Cache key for the import search result.
"""
return (
f"librenms_devices_import_{server_key}_{_build_filter_hash(api_filters)}_{_build_filter_hash(client_filters)}"
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,288 @@
"""Device filtering and retrieval from LibreNMS."""
import logging
from typing import List
from django.core.cache import cache
from .cache import get_import_search_cache_key
from ..librenms_api import LibreNMSAPI
logger = logging.getLogger(__name__)
def _safe_disabled(device: dict) -> int:
"""
Return 1 if the device is disabled, 0 otherwise.
Handles None, booleans, numeric strings, and common truthy/falsy tokens
(e.g. "true"/"yes"/"on" → 1, "false"/"no"/"off" → 0) without raising.
"""
val = device.get("disabled", 0)
if isinstance(val, bool):
return int(val)
if isinstance(val, str):
normalized = val.strip().lower()
if normalized in ("1", "true", "yes", "on"):
return 1
if normalized in ("0", "false", "no", "off", ""):
return 0
try:
int_val = int(val)
return 1 if int_val else 0
except (TypeError, ValueError):
return 0
def get_device_count_for_filters(
api: LibreNMSAPI,
filters: dict,
clear_cache: bool = False,
show_disabled: bool = True,
) -> int:
"""
Get count of LibreNMS devices matching filters.
This is a lightweight function to determine device count for background job
decision making. Uses the same caching as get_librenms_devices_for_import().
Args:
api: LibreNMS API client instance
filters: Filter dict with location, type, os, hostname, sysname keys
clear_cache: Whether to force cache refresh
show_disabled: Whether to include disabled devices
Returns:
int: Count of devices matching filters
"""
devices = get_librenms_devices_for_import(api, filters=filters, force_refresh=clear_cache)
# Filter out disabled devices if requested. LibreNMS's "disabled" field (1=disabled,
# 0=enabled) reflects manual device disablement; "status" reflects SNMP reachability.
# show_disabled controls the former: hidden when disabled==1, shown regardless of status.
if not show_disabled:
devices = [d for d in devices if _safe_disabled(d) != 1]
return len(devices)
def get_librenms_devices_for_import(
api: LibreNMSAPI = None,
filters: dict = None,
server_key: str = None,
*,
force_refresh: bool = False,
return_cache_status: bool = False,
) -> List[dict] | tuple[List[dict], bool]:
"""
Retrieve LibreNMS devices based on filters.
Args:
api: LibreNMSAPI instance (if not provided, creates one with server_key)
filters: Dict containing filter parameters:
- location: LibreNMS location/site filter
- type: Device type filter
- os: Operating system filter
- hostname: Hostname filter (partial match)
- sysname: System name filter (partial match)
- status: Device status filter (1=up, 0=down)
- disabled: Include disabled devices (0=active only, 1=all)
server_key: Key for specific server configuration (used if api not provided)
force_refresh: When True, bypass the cache and fetch fresh data
return_cache_status: When True, returns (devices, from_cache) tuple
Returns:
List of device dictionaries from LibreNMS, or tuple of (devices, from_cache)
if return_cache_status is True. from_cache=True means data was loaded from
existing cache; from_cache=False means data was just fetched from LibreNMS.
"""
try:
# Use provided API instance or create a new one
if api is None:
api = LibreNMSAPI(server_key=server_key)
# Build LibreNMS API filters using the type/query format
# LibreNMS API v0 expects ?type=X&query=Y format, not direct parameters
# NOTE: API only supports ONE type/query pair, so we'll use the most
# specific filter for the API and apply others client-side
api_filters = {}
client_filters = {} # Filters to apply after fetching from API
if filters:
# Check for status filter first - it has special handling
if filters.get("status") is not None:
# Normalize to int: form fields send strings ("1"/"0"), API may send ints
try:
status_val = int(filters["status"])
except (ValueError, TypeError):
status_val = None
# Status filter uses special types that don't need query param
if status_val == 1:
api_filters["type"] = "up"
elif status_val == 0:
api_filters["type"] = "down"
# Save ALL other filters for client-side filtering when status is used
if filters.get("location"):
client_filters["location"] = filters["location"]
if filters.get("type"):
client_filters["type"] = filters["type"]
if filters.get("os"):
client_filters["os"] = filters["os"]
if filters.get("hostname"):
client_filters["hostname"] = filters["hostname"]
if filters.get("sysname"):
client_filters["sysname"] = filters["sysname"]
if filters.get("hardware"):
client_filters["hardware"] = filters["hardware"]
else:
# Priority order for type/query filters: location > type > os > hostname > sysname
# Note: When sysname is combined with other filters, it's applied client-side for partial matching
# When sysname is alone, it uses API exact match (type=sysName)
# Note: hardware is always applied client-side for partial matching
# Use first available for API, save others for client-side filtering
if filters.get("location"):
api_filters["type"] = "location_id"
api_filters["query"] = filters["location"]
# Save remaining filters for client-side
if filters.get("type"):
client_filters["type"] = filters["type"]
if filters.get("os"):
client_filters["os"] = filters["os"]
if filters.get("hostname"):
client_filters["hostname"] = filters["hostname"]
if filters.get("sysname"):
client_filters["sysname"] = filters["sysname"]
if filters.get("hardware"):
client_filters["hardware"] = filters["hardware"]
elif filters.get("type"):
api_filters["type"] = "type"
api_filters["query"] = filters["type"]
# Save remaining filters for client-side
if filters.get("os"):
client_filters["os"] = filters["os"]
if filters.get("hostname"):
client_filters["hostname"] = filters["hostname"]
if filters.get("sysname"):
client_filters["sysname"] = filters["sysname"]
if filters.get("hardware"):
client_filters["hardware"] = filters["hardware"]
elif filters.get("os"):
api_filters["type"] = "os"
api_filters["query"] = filters["os"]
# Save remaining filters for client-side
if filters.get("hostname"):
client_filters["hostname"] = filters["hostname"]
if filters.get("sysname"):
client_filters["sysname"] = filters["sysname"]
if filters.get("hardware"):
client_filters["hardware"] = filters["hardware"]
elif filters.get("hostname"):
api_filters["type"] = "hostname"
api_filters["query"] = filters["hostname"]
# Save sysname and hardware for client-side
if filters.get("sysname"):
client_filters["sysname"] = filters["sysname"]
if filters.get("hardware"):
client_filters["hardware"] = filters["hardware"]
elif filters.get("sysname"):
# sysname-only filter: Use API exact match (type=sysName&query=<value>)
# This is safe - returns empty if no exact match found
api_filters["type"] = "sysName"
api_filters["query"] = filters["sysname"]
# Save hardware for client-side
if filters.get("hardware"):
client_filters["hardware"] = filters["hardware"]
elif filters.get("hardware"):
# hardware-only filter: apply client-side for partial matching
client_filters["hardware"] = filters["hardware"]
# Note: disabled filter isn't directly supported by LibreNMS API
# We'll filter client-side if needed
# Use caching to avoid repeated API calls
# Include both API and client filters in cache key (deterministic, cross-process stable).
# Use api.server_key (always resolved) rather than the raw server_key arg (may differ).
cache_key = get_import_search_cache_key(api.server_key, api_filters, client_filters)
from_cache = False
if force_refresh:
cache.delete(cache_key)
else:
cached_result = cache.get(cache_key)
if cached_result is not None:
# No need to deepcopy - cached data isn't mutated
devices = cached_result
from_cache = True
if return_cache_status:
return devices, from_cache
return devices
success, devices = api.list_devices(api_filters if api_filters else None)
if not success:
logger.error(f"Failed to retrieve devices from LibreNMS: {devices}")
# Cache a brief negative result to prevent hammering the API on repeated failures.
cache.set(cache_key, [], timeout=min(60, api.cache_timeout))
if return_cache_status:
return [], False
return []
# Apply client-side filters if any
if client_filters:
devices = _apply_client_filters(devices, client_filters)
# Cache using configured timeout (default 300s)
# No need to deepcopy - Django's cache backend handles serialization
cache.set(cache_key, devices, timeout=api.cache_timeout)
if return_cache_status:
return devices, from_cache
return devices
except Exception:
logger.exception("Error retrieving LibreNMS devices for import")
if return_cache_status:
return [], False
return []
def _apply_client_filters(devices: List[dict], filters: dict) -> List[dict]:
"""
Apply client-side filters to device list.
Args:
devices: List of device dicts from LibreNMS
filters: Dict of filters to apply (location, type, os, hostname, sysname)
Returns:
Filtered list of devices
"""
filtered = devices
if filters.get("location"):
location_id = str(filters["location"])
filtered = [d for d in filtered if str(d.get("location_id", "")) == location_id]
if filters.get("type"):
device_type = filters["type"].lower()
filtered = [d for d in filtered if (d.get("type") or "").lower() == device_type]
if filters.get("os"):
os_filter = filters["os"].lower()
filtered = [d for d in filtered if os_filter in (d.get("os") or "").lower()]
if filters.get("hostname"):
hostname_filter = filters["hostname"].lower()
filtered = [d for d in filtered if hostname_filter in (d.get("hostname") or "").lower()]
if filters.get("sysname"):
sysname_filter = filters["sysname"].lower()
filtered = [d for d in filtered if sysname_filter in (d.get("sysName") or "").lower()]
if filters.get("hardware"):
hardware_filter = filters["hardware"].lower()
filtered = [d for d in filtered if hardware_filter in (d.get("hardware") or "").lower()]
return filtered

View File

@@ -0,0 +1,44 @@
"""Permission check helpers for device import operations."""
from django.core.exceptions import PermissionDenied
def check_user_permissions(user, permissions):
"""
Check if user has all required permissions.
Args:
user: The user object to check permissions for
permissions: List of permission strings (e.g., ['dcim.add_device', 'dcim.add_interface'])
Returns:
tuple: (has_all_permissions: bool, missing_permissions: list[str])
Raises:
PermissionDenied: If user is None (no user context available)
"""
if user is None:
raise PermissionDenied("No user context available for permission check")
missing = [perm for perm in permissions if not user.has_perm(perm)]
return (len(missing) == 0, missing)
def require_permissions(user, permissions, action_description="perform this action"):
"""
Require user has all permissions, raising PermissionDenied if not.
Args:
user: The user object to check permissions for
permissions: List of permission strings
action_description: Human-readable description for error message
Raises:
PermissionDenied: If user lacks any required permission
"""
has_perms, missing = check_user_permissions(user, permissions)
if not has_perms:
missing_str = ", ".join(missing)
raise PermissionDenied(
f"You do not have permission to {action_description}. Missing permissions: {missing_str}"
)

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

View File

@@ -0,0 +1,257 @@
"""Virtual machine creation and import operations."""
import logging
from dcim.models import DeviceRole
from django.db import transaction
from django.utils import timezone
from virtualization.models import Cluster
from ..librenms_api import LibreNMSAPI
from .bulk_import import _is_job_cancelled
from .device_operations import _determine_device_name, fetch_device_with_cache, validate_device_for_import
from .permissions import require_permissions
logger = logging.getLogger(__name__)
def create_vm_from_librenms(
libre_device: dict,
validation: dict,
server_key: str = "default",
use_sysname: bool = True,
strip_domain: bool = False,
role=None,
):
"""
Create a NetBox VirtualMachine from LibreNMS device data.
Args:
libre_device: Device data from LibreNMS
validation: Validation result from validate_device_for_import with import_as_vm=True
use_sysname: If True, prefer sysName; if False, use hostname
server_key: LibreNMS server key used to store the librenms_id custom field
Returns:
Created VirtualMachine instance
Raises:
Exception if VM cannot be created
"""
from virtualization.models import VirtualMachine
if not validation["can_import"]:
raise ValueError(f"VM cannot be imported: {', '.join(validation['issues'])}")
# Extract matched objects from validation
cluster = validation["cluster"]["cluster"]
platform = validation["platform"].get("platform")
role = role if role is not None else validation.get("device_role", {}).get("role")
# Determine VM name - use pre-computed name if available (handles strip_domain),
# falling back to the validated resolved_name before recomputing from raw fields.
vm_name = libre_device.get("_computed_name") or validation.get("resolved_name")
if not vm_name:
vm_name = _determine_device_name(
libre_device,
use_sysname=use_sysname,
strip_domain=strip_domain,
device_id=libre_device.get("device_id"),
)
# Generate import timestamp comment
import_time = timezone.now().strftime("%Y-%m-%d %H:%M:%S %Z")
# Validate device_id before creating the VM so a missing/invalid value
# never leaves a VM without a librenms_id (partial persistence).
raw_device_id = libre_device["device_id"]
if isinstance(raw_device_id, bool):
raise ValueError(f"device_id is a boolean ({raw_device_id!r}); expected an integer")
librenms_device_id = int(raw_device_id)
from ..utils import set_librenms_device_id
# Create the VM and assign its LibreNMS ID atomically so a failure in
# set_librenms_device_id never leaves a VM without a mapping.
with transaction.atomic():
vm = VirtualMachine.objects.create(
name=vm_name,
cluster=cluster,
role=role,
platform=platform,
comments=f"Imported from LibreNMS (device_id={librenms_device_id}) by netbox-librenms-plugin on {import_time}",
)
set_librenms_device_id(vm, librenms_device_id, server_key)
vm.save()
logger.info(f"Created VM {vm.name} (ID: {vm.pk}) from LibreNMS device {libre_device['device_id']}")
return vm
def bulk_import_vms(
vm_imports: dict[int, dict[str, int]],
api: LibreNMSAPI,
sync_options: dict = None,
libre_devices_cache: dict = None,
job=None,
user=None,
) -> dict:
"""
Import multiple LibreNMS devices as VMs in NetBox.
Handles validation, cluster/role assignment, name determination,
and VM creation. Supports both synchronous and background job execution.
This function consolidates VM import logic that was previously duplicated
in BulkImportDevicesView and ImportDevicesJob, ensuring consistent behavior
across synchronous and background import paths.
Args:
vm_imports: Dict mapping device_id to {"cluster_id": int, "device_role_id": int}
api: LibreNMSAPI instance for device fetching
sync_options: Optional dict with use_sysname, strip_domain settings
libre_devices_cache: Optional pre-fetched device data cache
job: Optional JobRunner instance for background job logging/cancellation
user: User performing the import (for permission checks). If job is provided,
user is extracted from job.job.user if not explicitly passed.
Returns:
Dict with keys:
- success: List of {"device_id": int, "device": VM, "message": str}
- failed: List of {"device_id": int, "error": str}
- skipped: List of {"device_id": int, "reason": str}
Raises:
PermissionDenied: If user lacks required permissions
Example:
>>> # Synchronous import from view
>>> vm_imports = {123: {"cluster_id": 5, "device_role_id": 2}}
>>> result = bulk_import_vms(vm_imports, api, sync_options, user=request.user)
>>> print(f"Created {len(result['success'])} VMs")
>>>
>>> # Background job import
>>> result = bulk_import_vms(vm_imports, api, sync_options, cache, job=self)
"""
from netbox_librenms_plugin.import_validation_helpers import (
apply_cluster_to_validation,
apply_role_to_validation,
)
# Extract user from job if not explicitly provided
if user is None and job is not None:
user = getattr(job.job, "user", None)
# Check permissions at start of bulk operation
require_permissions(user, ["virtualization.add_virtualmachine"], "import VMs")
result = {"success": [], "failed": [], "skipped": []}
vm_ids = list(vm_imports.keys())
# Use job logger if available, otherwise standard logger
log = job.logger if job else logger
for idx, vm_id in enumerate(vm_ids, start=1):
# Check for job cancellation before first VM and every 5 thereafter
if job and (idx == 1 or idx % 5 == 0) and _is_job_cancelled(job):
log.warning(f"Job cancelled at VM {idx} of {len(vm_ids)}")
break
log.info(f"Processing VM {idx} of {len(vm_ids)}")
try:
# Fetch device data (uses cache helper)
libre_device = fetch_device_with_cache(vm_id, api, api.server_key, libre_devices_cache)
if not libre_device:
result["failed"].append(
{
"device_id": vm_id,
"error": f"Device {vm_id} not found in LibreNMS",
}
)
log.error(f"Device {vm_id} not found in LibreNMS")
continue
# Validate as VM
use_sysname_opt = sync_options.get("use_sysname", True) if sync_options else True
strip_domain_opt = sync_options.get("strip_domain", False) if sync_options else False
validation = validate_device_for_import(
libre_device,
import_as_vm=True,
api=api,
use_sysname=use_sysname_opt,
strip_domain=strip_domain_opt,
server_key=api.server_key,
)
# Check if VM already exists
if validation.get("existing_device"):
result["skipped"].append(
{
"device_id": vm_id,
"reason": f"VM already exists: {validation['existing_device'].name}",
}
)
log.info(f"VM already exists: {validation['existing_device'].name}")
continue
# Apply manual cluster and role selections
vm_mappings = vm_imports[vm_id]
cluster_id = vm_mappings.get("cluster_id")
role_id = vm_mappings.get("device_role_id")
if cluster_id:
cluster = Cluster.objects.filter(id=cluster_id).first()
if cluster:
apply_cluster_to_validation(validation, cluster)
else:
result["failed"].append(
{"device_id": vm_id, "error": f"Selected cluster (id={cluster_id}) no longer exists"}
)
continue
role = None
if role_id:
role = DeviceRole.objects.filter(id=role_id).first()
if role:
apply_role_to_validation(validation, role, is_vm=True)
else:
result["failed"].append(
{"device_id": vm_id, "error": f"Selected role (id={role_id}) no longer exists"}
)
continue
# Determine VM name
vm_name = _determine_device_name(
libre_device,
use_sysname=use_sysname_opt,
strip_domain=strip_domain_opt,
device_id=vm_id,
)
# Update validation with computed name
libre_device["_computed_name"] = vm_name
# Create VM
vm = create_vm_from_librenms(
libre_device,
validation,
use_sysname=use_sysname_opt,
strip_domain=strip_domain_opt,
server_key=api.server_key,
)
result["success"].append(
{
"device_id": vm_id,
"device": vm,
"message": f"VM {vm.name} created successfully",
}
)
log.info(f"Successfully imported VM {vm.name} (ID: {vm_id})")
except Exception as vm_error:
log.error(f"Failed to import VM {vm_id}: {vm_error}", exc_info=True)
result["failed"].append({"device_id": vm_id, "error": str(vm_error)})
return result