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