Files
netbox-librenms-plugin/netbox_librenms_plugin/import_utils/device_operations.py
Vlastislav Svatek 673e67106e
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
first commit
2026-06-05 10:39:05 +02:00

1004 lines
44 KiB
Python

"""Device validation, import, and fetch operations."""
import logging
from types import SimpleNamespace
from dcim.models import Device, DeviceRole, DeviceType, Rack, Site
from django.core.cache import cache
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from virtualization.models import Cluster # noqa: F401 — used by test mock.patch targets
from ..librenms_api import LibreNMSAPI
from ..utils import (
find_by_librenms_id,
find_matching_platform,
find_matching_site,
match_librenms_hardware_to_device_type,
set_librenms_device_id,
)
from .cache import get_import_device_cache_key
from .virtual_chassis import (
_generate_vc_member_name,
empty_virtual_chassis_data,
get_virtual_chassis_data,
update_vc_member_suggested_names,
)
logger = logging.getLogger(__name__)
def _try_chassis_device_type_match(api, device_id):
"""
Attempt device type matching using chassis inventory fields.
When the LibreNMS hardware string doesn't match any NetBox device type,
the chassis entity often contains a more standardized identifier
(e.g., entPhysicalName 'CHAS-BP-MX480-S' or entPhysicalModelName '710-017414')
that matches a DeviceType part_number or model.
Tries entPhysicalName first (typically the chassis part number),
then entPhysicalModelName as fallback.
Returns:
dict with matched/device_type/match_type keys, or None on failure.
"""
skip_values = {"", "-", "Unspecified", "BUILTIN", "None"}
try:
success, inventory = api.get_inventory_filtered(device_id, ent_physical_class="chassis")
if not success or not inventory:
return None
for item in inventory:
# Try entPhysicalName first (often the chassis part number like CHAS-BP-MX480-S)
for field in ("entPhysicalName", "entPhysicalModelName"):
value = item.get(field) or ""
if value and value not in skip_values:
chassis_match = match_librenms_hardware_to_device_type(value)
if chassis_match is None:
continue
if chassis_match["matched"]:
chassis_match["match_type"] = "chassis"
chassis_match["chassis_model"] = value
return chassis_match
except Exception:
logger.debug(f"Chassis inventory fallback failed for device {device_id}", exc_info=True)
return None
def _determine_device_name(
libre_device: dict,
use_sysname: bool = True,
strip_domain: bool = False,
device_id: int | str = None,
) -> str:
"""
Determine the device/VM name from LibreNMS data.
Centralized logic for building device names with consistent handling of:
- sysName vs hostname preference
- Domain stripping (avoiding IP addresses)
- Fallback to device_id when name is missing
Args:
libre_device: Device data from LibreNMS
use_sysname: If True, prefer sysName; if False, use hostname
strip_domain: If True, strip domain suffix (e.g., '.example.com')
device_id: LibreNMS device ID for fallback name generation
Returns:
str: The determined device name
Example:
>>> _determine_device_name({'sysName': 'router.example.com', 'hostname': 'router'},
... use_sysname=True, strip_domain=True)
'router'
"""
# Determine base name based on use_sysname preference
if use_sysname:
name = libre_device.get("sysName") or libre_device.get("hostname")
else:
name = libre_device.get("hostname") or libre_device.get("sysName")
# Fallback to device_id if no name found
if not name:
if device_id is not None:
name = f"device-{device_id}"
else:
name = libre_device.get("device_id", "unknown")
name = f"device-{name}"
# Strip domain if requested (but not for IP addresses)
if strip_domain and name and "." in name:
try:
from ipaddress import ip_address
ip_address(name)
# It's a valid IP address, don't strip
except ValueError:
# Not an IP, safe to strip domain
name = name.split(".")[0]
return name
def validate_device_for_import(
libre_device: dict,
import_as_vm: bool = False,
api: "LibreNMSAPI" = None,
*,
server_key: str = "default",
include_vc_detection: bool = True,
force_vc_refresh: bool = False,
use_sysname: bool = True,
strip_domain: bool = False,
) -> dict:
"""
Validate if a LibreNMS device can be imported to NetBox.
Performs comprehensive validation:
- Checks if device already exists in NetBox
- Validates required prerequisites (Site, DeviceType, DeviceRole for devices)
OR (Cluster for VMs)
- Provides smart matching for missing objects
- Detects virtual chassis/stack configuration (if API provided)
- Returns detailed validation status
Args:
libre_device: Device data from LibreNMS
import_as_vm: If True, validate for VM import instead of device import
api: Optional LibreNMSAPI instance for virtual chassis detection
include_vc_detection: Skip VC detection when False to speed up bulk operations
force_vc_refresh: When True, bypass cached VC data and re-query LibreNMS
use_sysname: If True, prefer sysName over hostname (matches import behaviour)
strip_domain: If True, strip domain suffix from device name
Returns:
dict: Validation result with structure:
{
'is_ready': bool, # Can import without user intervention
'can_import': bool, # Can import (possibly after configuration)
'import_as_vm': bool, # Whether importing as VM
'existing_device': Device or VirtualMachine or None,
'issues': List[str], # Blocking issues
'warnings': List[str], # Non-blocking warnings
'site': { # Only for devices
'found': bool,
'site': Site or None,
'match_type': str, # 'exact' or None
'suggestions': List[Site] # Alternative suggestions
},
'device_type': { # Only for devices
'found': bool,
'device_type': DeviceType or None,
'match_type': str, # 'exact' or None
'suggestions': List[dict] # Device types for user selection
},
'device_role': { # Only for devices
'found': bool, # Always False - requires manual selection
'role': DeviceRole or None,
'available_roles': List[DeviceRole] # All roles for user selection
},
'cluster': { # Only for VMs
'found': bool, # Always False - requires manual selection
'cluster': Cluster or None,
'available_clusters': List[Cluster] # All clusters for user selection
},
'platform': {
'found': bool,
'platform': Platform or None,
'match_type': str # 'exact' or None
}
}
Example:
>>> validation = validate_device_for_import(libre_device)
>>> if validation['is_ready']:
... import_single_device(libre_device['device_id'])
"""
result = {
"is_ready": False,
"can_import": False,
"import_as_vm": import_as_vm,
"resolved_name": None, # Final device name after applying user preferences
"existing_device": None,
"existing_match_type": None, # Track how existing device was matched
"serial_action": None, # None, "link", "conflict", "update_serial", "hostname_differs"
"serial_confirmed": False, # True when librenms_id match and serial matches
"serial_duplicate": False, # True when incoming serial is already on a different device
"librenms_id_needs_migration": False, # True when existing device has legacy bare-int ID
"name_matches": False, # True when existing device name matches LibreNMS sysName
"name_sync_available": False, # True when existing device name differs from sysName
"suggested_name": None, # sysName to suggest when name_sync_available is True
"device_type_mismatch": False, # True when existing device's type differs from LibreNMS
"issues": [],
"warnings": [],
"virtual_chassis": empty_virtual_chassis_data(),
"site": {
"found": False,
"site": None,
"match_type": None,
"suggestions": [],
},
"device_type": {
"found": False,
"device_type": None,
"match_type": None,
"suggestions": [],
},
"device_role": {
"found": False,
"role": None,
"available_roles": [],
},
"cluster": {
"found": False,
"cluster": None,
"available_clusters": [],
},
"platform": {"found": False, "platform": None, "match_type": None},
"rack": {
"found": False,
"rack": None,
"available_racks": [],
},
"naming_criteria": None, # Populated after resolved_name is set
}
try:
# 1. Check if device/VM already exists in NetBox
# Always check both Devices AND VMs to properly detect existing objects
librenms_id = libre_device.get("device_id")
hostname = _determine_device_name(
libre_device,
use_sysname=use_sysname,
strip_domain=strip_domain,
device_id=librenms_id,
)
result["resolved_name"] = hostname
_raw_sysname = libre_device.get("sysName") or ""
_raw_hostname = libre_device.get("hostname") or ""
if not _raw_sysname and not _raw_hostname:
_source = f"device-{librenms_id}"
elif use_sysname:
_source = "sysname" if _raw_sysname else "hostname"
else:
_source = "hostname" if _raw_hostname else "sysname"
result["naming_criteria"] = {
"use_sysname": use_sysname,
"strip_domain": strip_domain,
"raw_sysname": _raw_sysname,
"raw_hostname": _raw_hostname,
"source": _source,
}
logger.debug(
f"Checking for existing device/VM: "
f"librenms_id={librenms_id} (type={type(librenms_id).__name__}), "
f"hostname={hostname}"
)
from virtualization.models import VirtualMachine
server_key = api.server_key if api is not None else server_key
# Check for existing VM first (by librenms_id custom field).
# find_by_librenms_id() covers both the new per-server JSON format
# and legacy bare-integer values so neither is missed.
existing_vm = find_by_librenms_id(VirtualMachine, librenms_id, server_key)
if existing_vm:
logger.info(f"Found existing VM: {existing_vm.name} (matched by librenms_id={librenms_id})")
result["existing_device"] = existing_vm
result["existing_match_type"] = "librenms_id"
result["import_as_vm"] = True # Force VM mode since VM exists
result["can_import"] = False
# Detect legacy bare-integer or string-digit format so UI can offer a migration action.
# Direct access needed to detect legacy format for migration prompt:
# LibreNMSAPI.get_librenms_id() returns an int in both formats, so only the
# raw type check on custom_field_data reveals whether migration is needed.
_vm_cf_id = existing_vm.custom_field_data.get("librenms_id")
if (isinstance(_vm_cf_id, int) and not isinstance(_vm_cf_id, bool)) or (
isinstance(_vm_cf_id, str) and _vm_cf_id.isdigit()
):
result["librenms_id_needs_migration"] = True
# Check if name matches resolved name (accounts for use_sysname/strip_domain)
if hostname and existing_vm.name == hostname:
result["name_matches"] = True
elif hostname and existing_vm.name != hostname:
result["name_sync_available"] = True
result["suggested_name"] = hostname
# Check for existing Device (by librenms_id custom field).
# find_by_librenms_id() covers both the new per-server JSON format
# and legacy bare-integer values so neither is missed.
if not result["existing_device"]:
existing_device = find_by_librenms_id(Device, librenms_id, server_key)
if existing_device:
logger.info(f"Found existing device: {existing_device.name} (matched by librenms_id={librenms_id})")
result["existing_device"] = existing_device
result["existing_match_type"] = "librenms_id"
result["can_import"] = False
# Detect legacy bare-integer or string-digit format so UI can offer a migration action.
# Direct access needed to detect legacy format for migration prompt:
# LibreNMSAPI.get_librenms_id() returns an int in both formats, so only the
# raw type check on custom_field_data reveals whether migration is needed.
_dev_cf_id = existing_device.custom_field_data.get("librenms_id")
if (isinstance(_dev_cf_id, int) and not isinstance(_dev_cf_id, bool)) or (
isinstance(_dev_cf_id, str) and _dev_cf_id.isdigit()
):
result["librenms_id_needs_migration"] = True
# Check if name matches resolved name (VC-aware: compare against VC member name)
if hostname and existing_device.virtual_chassis and existing_device.vc_position:
incoming_serial = libre_device.get("serial") or ""
if incoming_serial == "-":
incoming_serial = ""
vc_expected_name = _generate_vc_member_name(
hostname,
existing_device.vc_position,
serial=incoming_serial or existing_device.serial or "",
)
if existing_device.name == vc_expected_name:
result["name_matches"] = True
else:
result["name_sync_available"] = True
result["suggested_name"] = vc_expected_name
elif hostname and existing_device.name == hostname:
result["name_matches"] = True
elif hostname and existing_device.name != hostname:
result["name_sync_available"] = True
result["suggested_name"] = hostname
# Check for serial drift on the linked device
incoming_serial = libre_device.get("serial") or ""
if incoming_serial and incoming_serial != "-":
if existing_device.serial and existing_device.serial == incoming_serial:
result["serial_confirmed"] = True
elif existing_device.serial and existing_device.serial != incoming_serial:
serial_conflict = (
Device.objects.filter(serial=incoming_serial).exclude(pk=existing_device.pk).first()
)
if serial_conflict:
result["serial_action"] = "conflict"
result["serial_duplicate"] = True
result["warnings"].append(
f"Serial conflict: incoming serial '{incoming_serial}' is already assigned to "
f"device '{serial_conflict.name}' (ID: {serial_conflict.pk}) in NetBox. "
f"Investigate which device should own this serial before updating."
)
else:
result["serial_action"] = "update_serial"
result["warnings"].append(
f"Serial number differs (NetBox: '{existing_device.serial}', "
f"LibreNMS: '{incoming_serial}'). Hardware may have been replaced."
)
# Only check hostname/serial/IP if not already matched by librenms_id
if not result["existing_device"]:
# Check by hostname/name - Check both VMs and Devices for conflicts
existing_vm = VirtualMachine.objects.filter(name__iexact=hostname).first()
existing_device = Device.objects.filter(name__iexact=hostname).first()
# If BOTH exist with same hostname, it's ambiguous - don't match either
if existing_vm and existing_device:
logger.warning(
f"Hostname conflict: Both VM '{existing_vm.name}' and Device "
f"'{existing_device.name}' exist with hostname '{hostname}'"
)
result["warnings"].append(
f"Both a VM and Device exist with hostname '{hostname}' in NetBox. "
f"Cannot determine which to match. Please set the librenms_id custom field on the correct object."
)
# Don't set existing_device, don't block import - let user proceed as new
# This allows them to import and then resolve the conflict manually
elif existing_vm:
logger.info(f"Found existing VM by hostname: {existing_vm.name}")
result["existing_device"] = existing_vm
result["existing_match_type"] = "hostname"
result["import_as_vm"] = True # Force VM mode since VM exists
result["warnings"].append(
f"VM with same hostname exists in NetBox as '{existing_vm.name}' (not linked to LibreNMS)"
)
result["can_import"] = False
elif existing_device:
logger.info(f"Found existing device by hostname: {existing_device.name}")
result["existing_device"] = existing_device
result["existing_match_type"] = "hostname"
# Check for serial conflict on hostname-matched device
incoming_serial = libre_device.get("serial") or ""
if incoming_serial and incoming_serial != "-" and existing_device.serial != incoming_serial:
serial_conflict = (
Device.objects.filter(serial=incoming_serial).exclude(pk=existing_device.pk).first()
)
if serial_conflict:
result["serial_action"] = "conflict"
result["serial_duplicate"] = True
result["warnings"].append(
f"Serial conflict: incoming serial '{incoming_serial}' is already assigned to "
f"device '{serial_conflict.name}' (ID: {serial_conflict.pk}) in NetBox. "
f"Investigate which device should own this serial before importing."
)
else:
result["serial_action"] = "update_serial"
result["warnings"].append(
f"Hostname matches but serial differs (NetBox: '{existing_device.serial}', "
f"LibreNMS: '{incoming_serial}'). Hardware may have been replaced."
)
else:
result["warnings"].append(
f"Device with same hostname exists in NetBox as '{existing_device.name}' (not linked to LibreNMS)"
)
result["can_import"] = False
# Check by serial number (strong physical match - hardware identity)
if not result["existing_device"]:
serial = libre_device.get("serial") or ""
if serial and serial != "-" and not import_as_vm:
existing_by_serial = Device.objects.filter(serial=serial).first()
if existing_by_serial:
logger.info(f"Found existing device by serial: {existing_by_serial.name} (serial={serial})")
result["existing_device"] = existing_by_serial
result["existing_match_type"] = "serial"
result["can_import"] = False
if existing_by_serial.name and existing_by_serial.name.lower() == hostname.lower():
result["warnings"].append(
f"Device with same serial and hostname exists as '{existing_by_serial.name}' "
f"(not linked to LibreNMS)"
)
result["serial_action"] = "link"
else:
result["warnings"].append(
f"Device with same serial ({serial}) exists as '{existing_by_serial.name}' "
f"but hostname differs (LibreNMS: '{hostname}'). Device may have been reinstalled."
)
result["serial_action"] = "hostname_differs"
# Check by primary IP (weaker match, IP could be reassigned) - only for devices
if not result["existing_device"]:
primary_ip = libre_device.get("ip")
if primary_ip and not import_as_vm:
from ipam.models import IPAddress
existing_ip = IPAddress.objects.filter(address__net_host=primary_ip).first()
if existing_ip and existing_ip.assigned_object:
device = (
existing_ip.assigned_object.device
if hasattr(existing_ip.assigned_object, "device")
else None
)
if device:
result["existing_device"] = device
result["existing_match_type"] = "primary_ip"
result["warnings"].append(
f"IP address {primary_ip} already assigned to device '{device.name}' (not linked to LibreNMS)"
)
result["can_import"] = False
# Refresh local variable to reflect any VM-mode adjustments made during detection
# (e.g. existing VM found by hostname sets result["import_as_vm"] = True)
import_as_vm = result["import_as_vm"]
# Validate based on import type (Device or VM)
if import_as_vm:
# Always populate available clusters for all VMs (new or existing) so
# the cluster dropdown has options whether creating or updating a VM.
cache_key = "librenms_import_all_clusters"
all_clusters = cache.get(cache_key)
if all_clusters is None:
all_clusters = list(Cluster.objects.all())
# Use API cache timeout if available, otherwise use default 5 minutes
cache_timeout = api.cache_timeout if api else 300
cache.set(cache_key, all_clusters, cache_timeout)
result["cluster"]["available_clusters"] = all_clusters
if import_as_vm:
if not result.get("existing_device"):
# 2. For NEW VMs: Validate Cluster (required) - Must be manually selected
result["cluster"]["found"] = False
result["issues"].append("Cluster must be manually selected before importing as VM")
# Skip device-specific validations for all VMs (new and existing)
result["site"]["found"] = True # Not required for VMs
result["device_type"]["found"] = True # Not required for VMs
result["device_role"]["found"] = True # Not required for VMs
else:
# 2. For Devices: Validate Site (required)
location = libre_device.get("location", "")
site_match = find_matching_site(location)
result["site"] = site_match
if not site_match["found"]:
if not result.get("existing_device"):
result["issues"].append(f"No matching site found for location: '{location}'")
# Get alternative suggestions
if location:
all_sites = Site.objects.all()[:10] # Limit for performance
result["site"]["suggestions"] = list(all_sites)
# 3. Validate DeviceType (required)
hardware = libre_device.get("hardware", "")
dt_match = match_librenms_hardware_to_device_type(hardware)
if dt_match is None:
result["device_type"]["found"] = False
result["device_type"]["device_type"] = None
result["device_type"]["match_type"] = "ambiguous"
if not result.get("existing_device"):
result["issues"].append(
f"Multiple device types match hardware '{hardware}' — resolve the ambiguity in NetBox."
)
else:
# Chassis inventory fallback: when hardware doesn't match,
# try the chassis entPhysicalModelName as an additional lookup source
if not dt_match["matched"] and api:
device_id = libre_device.get("device_id")
if device_id:
chassis_match = _try_chassis_device_type_match(api, device_id)
if chassis_match and chassis_match["matched"]:
dt_match = chassis_match
# Update result keys individually to preserve the existing schema (especially "found")
result["device_type"]["found"] = dt_match["matched"]
result["device_type"]["device_type"] = dt_match.get("device_type")
result["device_type"]["match_type"] = dt_match.get("match_type")
if not result["device_type"]["found"] and result["device_type"].get("match_type") != "ambiguous":
result["device_type"]["found"] = False
if not result.get("existing_device"):
result["issues"].append(f"No matching device type found for hardware: '{hardware}'")
# Get some device types for user to choose from
all_device_types = DeviceType.objects.all()[:10]
result["device_type"]["suggestions"] = [
{
"device_type": dt,
"similarity": 0.0, # No fuzzy matching, just showing options
"match_field": None,
}
for dt in all_device_types
]
if not result.get("existing_device"):
# 4. DeviceRole (required for new devices) - Must be manually selected
logger.debug(f"[{hostname}] Issues BEFORE adding role issue: {result['issues']}")
result["device_role"]["found"] = False
result["issues"].append("Device role must be manually selected before import")
logger.debug(f"[{hostname}] Issues AFTER adding role issue: {result['issues']}")
# Provide list of available roles for user selection (cached)
cache_key = "librenms_import_all_roles"
all_roles = cache.get(cache_key)
if all_roles is None:
all_roles = list(DeviceRole.objects.all())
# Use API cache timeout if available, otherwise use default 5 minutes
cache_timeout = api.cache_timeout if api else 300
cache.set(cache_key, all_roles, cache_timeout)
result["device_role"]["available_roles"] = all_roles
# 4b. Rack (optional) - Provide available racks for the matched site
if site_match["found"] and site_match["site"]:
site = site_match["site"]
# Use cache to optimize rack lookups per site
cache_key = f"librenms_import_racks_site_{site.pk}"
available_racks = cache.get(cache_key)
if available_racks is None:
# Query racks for this site - include both:
# 1. Racks assigned to locations within the site
# 2. Racks directly assigned to the site (without location)
available_racks = list(
Rack.objects.filter(Q(location__site=site) | Q(site=site))
.select_related("location", "site")
.order_by("location__name", "name")
)
# Use API cache timeout if available, otherwise use default 5 minutes
cache_timeout = api.cache_timeout if api else 300
cache.set(cache_key, available_racks, cache_timeout)
result["rack"]["available_racks"] = available_racks
# Rack is optional, don't add to issues
result["rack"]["found"] = True # Mark as "found" even if None (optional field)
# Skip VM-specific validations for devices
result["cluster"]["found"] = True # Not required for devices
# 5. Match Platform (optional - same for both devices and VMs)
os = libre_device.get("os", "")
platform_match = find_matching_platform(os)
result["platform"] = platform_match
if not platform_match["found"] and os:
result["warnings"].append(f"No matching platform found for OS: '{os}'")
# 6. Additional validations
if not hostname:
result["issues"].append("Device has no hostname")
# 7. Virtual chassis detection (only for devices, not VMs)
if include_vc_detection and not import_as_vm and api is not None:
device_id = libre_device.get("device_id")
if device_id:
try:
logger.debug(f"Calling get_virtual_chassis_data for device {device_id}")
vc_detection = get_virtual_chassis_data(api, device_id, force_refresh=force_vc_refresh)
logger.debug(
f"VC detection result: is_stack={vc_detection.get('is_stack')}, "
f"member_count={vc_detection.get('member_count')}, "
f"members={len(vc_detection.get('members', []))}"
)
if vc_detection:
result["virtual_chassis"] = vc_detection
if vc_detection["is_stack"]:
logger.debug(
f"Virtual chassis CONFIRMED for device {hostname}: "
f"{vc_detection['member_count']} members"
)
result["virtual_chassis"] = update_vc_member_suggested_names(vc_detection, hostname)
except Exception as e:
logger.exception(f"Exception during VC detection for device {hostname}: {e}")
result["virtual_chassis"]["detection_error"] = str(e)
else:
logger.debug(f"No device_id found for {hostname}")
# 8. Determine if device/VM is ready to import
if result["existing_device"]:
# Already matched - can_import was already set to False
result["is_ready"] = False
# Populate role from existing device so the modal shows it
existing = result["existing_device"]
if hasattr(existing, "role") and existing.role:
result["device_role"]["found"] = True
result["device_role"]["role"] = existing.role
# Check for device type mismatch between existing device and LibreNMS
if hasattr(existing, "device_type") and existing.device_type:
librenms_dt = result["device_type"].get("device_type")
if librenms_dt and existing.device_type.pk != librenms_dt.pk:
result["device_type_mismatch"] = True
result["warnings"].append(
f"Device type mismatch: NetBox has '{existing.device_type}' "
f"but LibreNMS reports '{librenms_dt}'. "
f"This may indicate the wrong device was matched."
)
else:
result["can_import"] = len(result["issues"]) == 0
if import_as_vm:
# For VMs: only cluster is required
result["is_ready"] = result["can_import"] and result["cluster"]["found"]
else:
# For Devices: site, device_type, and device_role are required
result["is_ready"] = (
result["can_import"]
and result["site"]["found"]
and result["device_type"]["found"]
and result["device_role"]["found"]
)
logger.debug(
f"Validation for {libre_device.get('hostname')} ({'VM' if import_as_vm else 'Device'}): "
f"issues={len(result['issues'])}, can_import={result['can_import']}, "
f"issues_list={result['issues']}"
)
return result
except Exception as e:
logger.exception(f"Error validating device for import: {libre_device.get('hostname', 'unknown')}")
result["issues"].append(f"Validation error: {str(e)}")
return result
def import_single_device(
device_id: int,
server_key: str = None,
validation: dict = None,
manual_mappings: dict = None,
sync_options: dict = None,
libre_device: dict = None,
) -> dict:
"""
Import a single LibreNMS device to NetBox.
Args:
device_id: LibreNMS device ID
server_key: LibreNMS server configuration key
validation: Pre-computed validation dict (optional)
manual_mappings: Manual object mappings (optional):
- site_id: NetBox Site ID
- device_type_id: NetBox DeviceType ID
- device_role_id: NetBox DeviceRole ID
- platform_id: NetBox Platform ID (optional)
- rack_id: NetBox Rack ID (optional)
sync_options: Sync options (optional):
- sync_interfaces: bool (default True)
- sync_cables: bool (default True)
- sync_ips: bool (default True)
- sync_fields: bool (default True)
libre_device: Pre-fetched LibreNMS device data (optional).
If provided, skips API call to fetch device info.
Returns:
dict: Import result with structure:
{
'success': bool,
'device': Device object or None,
'message': str,
'error': str or None,
'synced': {
'interfaces': int,
'cables': int,
'ip_addresses': int
}
}
"""
try:
api = LibreNMSAPI(server_key=server_key)
# Use pre-fetched device data if provided, otherwise fetch from API
if libre_device is None:
success, libre_device = api.get_device_info(device_id)
if not success or not libre_device:
return {
"success": False,
"device": None,
"message": "",
"error": f"Failed to retrieve device {device_id} from LibreNMS",
"synced": {},
}
# Validate device if validation not provided
if validation is None:
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,
)
# Check if device already exists
if validation.get("existing_device"):
return {
"success": False,
"device": validation["existing_device"],
"message": "",
"error": f"Device already exists: {validation['existing_device'].name}",
"synced": {},
}
# Use validation-derived matches, allow manual mappings to override specific fields
site = validation["site"].get("site")
device_type = validation["device_type"].get("device_type")
device_role = validation["device_role"].get("role")
platform = validation["platform"].get("platform")
rack = validation.get("rack", {}).get("rack")
if manual_mappings:
site = Site.objects.filter(id=manual_mappings.get("site_id")).first() or site
device_type = DeviceType.objects.filter(id=manual_mappings.get("device_type_id")).first() or device_type
device_role = DeviceRole.objects.filter(id=manual_mappings.get("device_role_id")).first() or device_role
platform_id = manual_mappings.get("platform_id")
if platform_id:
from dcim.models import Platform
platform = Platform.objects.filter(id=platform_id).first() or platform
rack_id = manual_mappings.get("rack_id")
if rack_id:
rack = Rack.objects.select_related("location", "site").filter(id=rack_id).first() or rack
# Validate required fields
if not site:
return {
"success": False,
"device": None,
"message": "",
"error": "Site is required but not provided",
"synced": {},
}
if not device_type:
return {
"success": False,
"device": None,
"message": "",
"error": "Device type is required but not provided",
"synced": {},
}
if not device_role:
return {
"success": False,
"device": None,
"message": "",
"error": "Device role is required but not provided",
"synced": {},
}
# Create device in NetBox
with transaction.atomic():
# Use pre-computed resolved_name from validation when available so the
# created device name matches exactly what was displayed in the import UI.
# Only fall back to recomputing from sync_options when no validation exists.
if validation and validation.get("resolved_name"):
device_name = validation["resolved_name"]
else:
use_sysname = sync_options.get("use_sysname", True) if sync_options else True
strip_domain = sync_options.get("strip_domain", False) if sync_options else False
device_name = _determine_device_name(
libre_device,
use_sysname=use_sysname,
strip_domain=strip_domain,
device_id=device_id,
)
# Generate import timestamp comment
import_time = timezone.now().strftime("%Y-%m-%d %H:%M:%S %Z")
_cf_proxy = SimpleNamespace(custom_field_data={})
set_librenms_device_id(_cf_proxy, device_id, api.server_key)
device_data = {
"name": device_name,
"site": site,
"device_type": device_type,
"role": device_role,
"status": "active" if libre_device.get("status") == 1 else "offline",
"comments": f"Imported from LibreNMS by netbox-librenms-plugin on {import_time}",
"custom_field_data": _cf_proxy.custom_field_data,
}
# Add optional fields
if platform:
device_data["platform"] = platform
if rack:
device_data["rack"] = rack
serial = libre_device.get("serial", "")
if serial and serial != "-":
device_data["serial"] = serial
location_name = libre_device.get("location", "")
if location_name and location_name != "-":
from dcim.models import Location
# Try to find matching location within the site
location = Location.objects.filter(site=site, name__iexact=location_name).first()
if location:
device_data["location"] = location
# Create the device
device = Device(**device_data)
device.full_clean()
device.save()
# Sync additional data based on options
sync_options = sync_options or {}
synced = {"interfaces": 0, "cables": 0, "ip_addresses": 0}
try:
# Sync interfaces
if sync_options.get("sync_interfaces", True):
# This is simplified - would need proper request context
# For now, just log that it should be done
logger.info(f"Interface sync should be performed for device {device.name}")
# Sync cables
if sync_options.get("sync_cables", True):
logger.info(f"Cable sync should be performed for device {device.name}")
# Sync IP addresses
if sync_options.get("sync_ips", True):
logger.info(f"IP address sync should be performed for device {device.name}")
except Exception as e:
logger.warning(f"Error during post-import sync: {str(e)}")
# Don't fail the import if sync fails
return {
"success": True,
"device": device,
"message": f"Successfully imported device: {device.name}",
"error": None,
"synced": synced,
}
except Exception as e:
logger.exception(f"Error importing device {device_id}")
return {
"success": False,
"device": None,
"message": "",
"error": str(e),
"synced": {},
}
def get_librenms_device_by_id(api: LibreNMSAPI, device_id: int) -> dict:
"""
Retrieve a single device from LibreNMS by ID.
Args:
api: LibreNMSAPI instance
device_id: LibreNMS device ID
Returns:
Device dictionary or None if not found
"""
try:
# Use the dedicated API endpoint to get device by ID
success, device = api.get_device_info(device_id)
if success and device:
return device
logger.warning(f"Device {device_id} not found in LibreNMS")
return None
except Exception as e:
logger.exception(f"Failed to get device {device_id} from LibreNMS: {e}")
return None
def fetch_device_with_cache(
device_id: int,
api: LibreNMSAPI,
server_key: str = None,
libre_devices_cache: dict = None,
) -> dict | None:
"""
Fetch LibreNMS device from cache or API with automatic caching.
Checks three sources in order:
1. Pre-fetched cache dict (if provided)
2. Django cache (Redis/memory)
3. LibreNMS API (caches result for future use)
This function consolidates the device fetching pattern used throughout
the import workflow, eliminating code duplication.
Args:
device_id: LibreNMS device ID to fetch
api: LibreNMSAPI instance for fallback API calls
server_key: Optional server key for multi-server setups (defaults to api.server_key)
libre_devices_cache: Optional pre-fetched device cache dict
Returns:
Device dict from LibreNMS, or None if not found
Example:
>>> # Simple usage
>>> libre_device = fetch_device_with_cache(123, api)
>>> if libre_device:
... print(libre_device['hostname'])
>>>
>>> # With pre-fetched cache dict
>>> cache_dict = {123: {...}, 456: {...}}
>>> libre_device = fetch_device_with_cache(123, api, libre_devices_cache=cache_dict)
"""
# Check pre-fetched cache dict first (fastest)
if libre_devices_cache and device_id in libre_devices_cache:
return libre_devices_cache[device_id]
# Check Django cache
cache_key = get_import_device_cache_key(device_id, server_key or api.server_key)
libre_device = cache.get(cache_key)
if not libre_device:
# Fallback to API fetch
libre_device = get_librenms_device_by_id(api, device_id)
if libre_device:
# Cache for future use
cache.set(cache_key, libre_device, timeout=api.cache_timeout)
return libre_device