diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 3560a93af3..7024351758 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -98,6 +98,8 @@ Below are the dev container defaults. The field name to change these defaults is - Plugin loader: enabled; reads `.devcontainer/config/plugin-config.py` if present - If `plugin-config.py` is missing: plugin is enabled with empty config (features won’t work until configured) + + ## πŸ”§ Configuration ### NetBox Version and Environment (use .devcontainer/.env) diff --git a/.devcontainer/scripts/diagnose.sh b/.devcontainer/scripts/diagnose.sh index 133e6ca97f..be7596d699 100755 --- a/.devcontainer/scripts/diagnose.sh +++ b/.devcontainer/scripts/diagnose.sh @@ -1,4 +1,5 @@ #!/bin/bash +# netbox-librenms-plugin devcontainer script echo "πŸ” DevContainer Startup Diagnostics" echo "==================================" diff --git a/.devcontainer/scripts/load-aliases.sh b/.devcontainer/scripts/load-aliases.sh index 65149d6198..feac6ee98f 100755 --- a/.devcontainer/scripts/load-aliases.sh +++ b/.devcontainer/scripts/load-aliases.sh @@ -1,4 +1,5 @@ #!/bin/bash +# netbox-librenms-plugin devcontainer script # Quick alias loader for current session # Usage: source .devcontainer/scripts/load-aliases.sh diff --git a/.devcontainer/scripts/setup.sh b/.devcontainer/scripts/setup.sh index 588fe652ab..7f4278fd46 100755 --- a/.devcontainer/scripts/setup.sh +++ b/.devcontainer/scripts/setup.sh @@ -1,4 +1,5 @@ #!/bin/bash +# netbox-librenms-plugin devcontainer script set -e echo "πŸš€ Setting up NetBox LibreNMS Plugin development environment..." diff --git a/.devcontainer/scripts/start-netbox.sh b/.devcontainer/scripts/start-netbox.sh index 789dcb845a..d5e4796600 100755 --- a/.devcontainer/scripts/start-netbox.sh +++ b/.devcontainer/scripts/start-netbox.sh @@ -1,4 +1,5 @@ #!/bin/bash +# netbox-librenms-plugin devcontainer script # Check if we should run in background or foreground BACKGROUND=false @@ -18,7 +19,6 @@ if [ "$CODESPACES" = "true" ] && [ -n "$CODESPACE_NAME" ]; then echo "πŸ”— GitHub Codespaces detected" else ACCESS_URL="http://localhost:8000" - echo "πŸ› Debug: ACCESS_URL is set to: $ACCESS_URL" fi # Load shared process management helpers diff --git a/.devcontainer/scripts/welcome.sh b/.devcontainer/scripts/welcome.sh index 9328d663aa..e273313766 100755 --- a/.devcontainer/scripts/welcome.sh +++ b/.devcontainer/scripts/welcome.sh @@ -1,4 +1,5 @@ #!/bin/bash +# netbox-librenms-plugin devcontainer script # Ensure aliases are available in the postAttach terminal session source "$(dirname "$0")/load-aliases.sh" 2>/dev/null @@ -44,7 +45,7 @@ if [ -n "$CODESPACES" ]; then echo " πŸ’‘ Click the link in the Ports panel or look for the 'Open in Browser' button" else echo "πŸ–₯️ Local Development Environment:" - echo " NetBox will be available at: http://localhost:8000 (paste into you browser)" + echo " NetBox will be available at: http://localhost:8000 (paste into your browser)" fi echo "" diff --git a/.github/workflows/lint-format.yaml b/.github/workflows/lint-format.yaml index 3e12242f63..055f809cc5 100644 --- a/.github/workflows/lint-format.yaml +++ b/.github/workflows/lint-format.yaml @@ -2,13 +2,7 @@ name: Lint and Format on: push: - branches: - - master - - develop pull_request: - branches: - - master - - develop jobs: format-and-lint: @@ -20,8 +14,7 @@ jobs: - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: - python-version: '3.9' - cache: 'pip' + python-version: '3.12' - name: Install dependencies run: | @@ -29,22 +22,7 @@ jobs: pip install ruff - name: Run Ruff linting - run: | - echo "::group::Ruff Linting" - ruff check . --output-format=github - echo "::endgroup::" + run: ruff check . - name: Run Ruff formatting check - run: | - echo "::group::Ruff Formatting" - ruff format --check . - echo "::endgroup::" - - - name: Report formatting issues - if: failure() - run: | - echo "::error::Formatting or linting issues detected!" - echo "To fix locally, run:" - echo " ruff check --fix ." - echo " ruff format ." - echo "Then commit and push the changes." + run: ruff format --check . diff --git a/contrib/README.md b/contrib/README.md new file mode 100644 index 0000000000..8714ac5342 --- /dev/null +++ b/contrib/README.md @@ -0,0 +1,28 @@ +# Contrib: Example Mapping Files + +This directory contains example YAML mapping files for bulk import into the +NetBox LibreNMS Plugin. Each file can be imported via the plugin's bulk import +feature in the NetBox UI. + +## How to Import + +1. Navigate to the mapping page (e.g., **LibreNMS β†’ Device Type Mappings**) +2. Click the **Import** button (upload icon) in the top right +3. Select **YAML** format +4. Paste the contents of the relevant YAML file +5. Click **Submit** + +## Available Mappings + +| File | Description | +|------|-------------| +| `interface_type_mappings.yaml` | Maps LibreNMS interface types + speeds to NetBox interface types | +| `device_type_mappings.yaml` | Maps LibreNMS hardware strings to NetBox device types | +| `module_type_mappings.yaml` | Maps LibreNMS inventory model names to NetBox module types (incl. transceivers) | +| `module_bay_mappings.yaml` | Maps LibreNMS inventory container names to NetBox module bay names | + +## Customisation + +These files are **examples** β€” adjust values to match the device types, module +types, and interface types defined in your NetBox instance. The `netbox_*` +fields must reference objects that already exist in your NetBox. diff --git a/contrib/device_type_mappings.yaml b/contrib/device_type_mappings.yaml new file mode 100644 index 0000000000..2dec241524 --- /dev/null +++ b/contrib/device_type_mappings.yaml @@ -0,0 +1,73 @@ +# Device Type Mappings +# +# Maps LibreNMS hardware strings to NetBox device types. +# Import via: LibreNMS Plugin > Device Type Mappings > Import +# +# Fields: +# librenms_hardware β€” Hardware string exactly as shown in LibreNMS +# netbox_device_type β€” NetBox DeviceType (matched by model name or ID) +# description β€” Optional note +# +# The librenms_hardware value is matched case-insensitively. +# These mappings are checked BEFORE the built-in part_number/model fallback. + +# Juniper β€” LibreNMS reports verbose marketing names +- librenms_hardware: "Juniper MX480 Internet Backbone Router" + netbox_device_type: "MX480" + description: "Juniper MX480 chassis" + +- librenms_hardware: "Juniper MX960 Internet Backbone Router" + netbox_device_type: "MX960" + description: "Juniper MX960 chassis" + +- librenms_hardware: "Juniper MX304 Edge Router" + netbox_device_type: "MX304" + description: "Juniper MX304 edge router" + +- librenms_hardware: "JNP10008 [PTX10008]" + netbox_device_type: "PTX10008" + description: "Juniper PTX10008 core router" + +- librenms_hardware: "JNP7100-32C [ACX7100-32C]" + netbox_device_type: "ACX7100-32C" + description: "Juniper ACX7100-32C" + +- librenms_hardware: "JNP7024 [ACX7024]" + netbox_device_type: "ACX7024" + description: "Juniper ACX7024" + +- librenms_hardware: "Juniper JNP10008 Internet Backbone Router" + netbox_device_type: "PTX10008" + description: "Juniper PTX10008 (alternate hardware string)" + +- librenms_hardware: "Juniper VRR Internet Backbone Router" + netbox_device_type: "VRR" + description: "Juniper Virtual Route Reflector" + +# Nokia β€” model string matches directly in most cases +- librenms_hardware: "7750 SR-7s" + netbox_device_type: "7750 SR-7s" + description: "Nokia 7750 SR-7s service router" + +# Cisco β€” often matches by part_number but not always +- librenms_hardware: "WS-C4900M" + netbox_device_type: "WS-C4900M" + description: "Cisco Catalyst 4900M" + +# Cisco IOS XR +- librenms_hardware: "8201-SYS" + netbox_device_type: "8201" + description: "Cisco 8201 (hardware string differs from model)" + +# UfiSpace β€” LibreNMS reports SONiC/ONIE platform names +- librenms_hardware: "x86-64-ufispace-s9610-36d-r0" + netbox_device_type: "S9610-36D" + description: "UfiSpace S9610-36D" + +- librenms_hardware: "x86-64-ufispace-s9610-46dx-r0" + netbox_device_type: "S9610-46DX" + description: "UfiSpace S9610-46DX" + +- librenms_hardware: "x86-64-ufispace-s9700-53dx-r9" + netbox_device_type: "S9700-53DX" + description: "UfiSpace S9700-53DX" diff --git a/contrib/interface_name_rules.yaml b/contrib/interface_name_rules.yaml new file mode 100644 index 0000000000..52da69dff5 --- /dev/null +++ b/contrib/interface_name_rules.yaml @@ -0,0 +1,200 @@ +# Interface Name Rules +# +# Post-install interface rename rules for module types where NetBox's +# position-based naming can't produce the correct interface name. +# +# Covers two scenarios: +# 1. Converter offset β€” e.g., GLC-T inside CVR-X2-SFP needs port numbering +# that accounts for the converter's position in the parent module bay. +# 2. Breakout channels β€” e.g., QSFP+ 4x10G produces multiple sub-interfaces +# from a single physical port. +# +# Template variables: +# {slot} β€” Top-level slot/module bay position +# {bay_position} β€” Position of the bay this module is installed into (raw) +# {bay_position_num} β€” Numeric suffix of bay position (e.g., "swp1" β†’ "1") +# {parent_bay_position} β€” Position of the parent module's bay +# {sfp_slot} β€” Numeric sub-bay index within the parent module +# {base} β€” Original interface name from the NetBox module template +# {channel} β€” Breakout channel number (iterated) +# +# Arithmetic expressions are supported inside braces: +# {8 + ({parent_bay_position} - 1) * 2 + {sfp_slot}} +# +# Bulk import via: LibreNMS Plugin > Settings > Interface Name Rules > Import + +# --- Converter Offset Examples --- + +# SFP-1G-T (1G copper SFP, covers GLC-T/GLC-TE) in CVR-X2-SFP converter +# X2 bays are numbered 1-N; each converter holds 2 SFP slots +# Resulting interface: GigabitEthernet/ +- module_type: SFP-1G-T + parent_module_type: CVR-X2-SFP + name_template: "GigabitEthernet{slot}/{8 + ({parent_bay_position} - 1) * 2 + {sfp_slot}}" + channel_count: 0 + channel_start: 0 + description: "SFP-1G-T in CVR-X2-SFP: offset port numbering for X2-to-SFP conversion" + +# --- Breakout Channel Examples --- + +# QSFP-4X10G-LR breakout β€” Juniper-style (channels start at 0) +- module_type: QSFP-4X10G-LR + name_template: "{base}:{channel}" + channel_count: 4 + channel_start: 0 + description: "QSFP+ 4x10G-LR breakout with Juniper-style channel numbering (0-3)" + +# QSFP-4X10G-SR breakout β€” Juniper-style (channels start at 0) +- module_type: QSFP-4X10G-SR + name_template: "{base}:{channel}" + channel_count: 4 + channel_start: 0 + description: "QSFP+ 4x10G-SR breakout with Juniper-style channel numbering (0-3)" + +# --- Commented Examples --- + +# QSFP+ 4x10G breakout β€” Cisco-style (channels start at 1) +# - module_type: QSFP-4X10G-LR +# name_template: "{base}:{channel}" +# channel_count: 4 +# channel_start: 1 +# description: "QSFP+ 4x10G breakout with Cisco-style channel numbering (1-4)" + +# --- UfiSpace/Arcos Breakout Rules --- +# UfiSpace switches use swpNsC naming for breakout interfaces. +# bay_position_num extracts the numeric suffix from the bay name (e.g., "swp1" β†’ "1"). +# Channels start at 1, with 2 channels per 100G QSFP28 (2x100G breakout). + +# S9610-36D breakout rules +- module_type: QSFP-100G-LR4 + device_type: S9610-36D + name_template: "swp{bay_position_num}s{channel}" + channel_count: 2 + channel_start: 1 + description: "UfiSpace QSFP28 2x100G breakout" +- module_type: QSFP-100G-SR4 + device_type: S9610-36D + name_template: "swp{bay_position_num}s{channel}" + channel_count: 2 + channel_start: 1 + description: "UfiSpace QSFP28 2x100G breakout" +- module_type: QSFP-100G-SWDM4 + device_type: S9610-36D + name_template: "swp{bay_position_num}s{channel}" + channel_count: 2 + channel_start: 1 + description: "UfiSpace QSFP28 2x100G breakout" +- module_type: QSFP-100G-ZR + device_type: S9610-36D + name_template: "swp{bay_position_num}s{channel}" + channel_count: 2 + channel_start: 1 + description: "UfiSpace QSFP28 2x100G breakout" + +# S9610-46DX breakout rules +- module_type: QSFP-100G-LR4 + device_type: S9610-46DX + name_template: "swp{bay_position_num}s{channel}" + channel_count: 2 + channel_start: 1 + description: "UfiSpace QSFP28 2x100G breakout" +- module_type: QSFP-100G-SR4 + device_type: S9610-46DX + name_template: "swp{bay_position_num}s{channel}" + channel_count: 2 + channel_start: 1 + description: "UfiSpace QSFP28 2x100G breakout" +- module_type: QSFP-100G-SWDM4 + device_type: S9610-46DX + name_template: "swp{bay_position_num}s{channel}" + channel_count: 2 + channel_start: 1 + description: "UfiSpace QSFP28 2x100G breakout" +- module_type: QSFP-100G-ZR + device_type: S9610-46DX + name_template: "swp{bay_position_num}s{channel}" + channel_count: 2 + channel_start: 1 + description: "UfiSpace QSFP28 2x100G breakout" + +# S9700-53DX breakout rules +- module_type: QSFP-100G-LR4 + device_type: S9700-53DX + name_template: "swp{bay_position_num}s{channel}" + channel_count: 2 + channel_start: 1 + description: "UfiSpace QSFP28 2x100G breakout" +- module_type: QSFP-100G-SR4 + device_type: S9700-53DX + name_template: "swp{bay_position_num}s{channel}" + channel_count: 2 + channel_start: 1 + description: "UfiSpace QSFP28 2x100G breakout" +- module_type: QSFP-100G-SWDM4 + device_type: S9700-53DX + name_template: "swp{bay_position_num}s{channel}" + channel_count: 2 + channel_start: 1 + description: "UfiSpace QSFP28 2x100G breakout" +- module_type: QSFP-100G-ZR + device_type: S9700-53DX + name_template: "swp{bay_position_num}s{channel}" + channel_count: 2 + channel_start: 1 + description: "UfiSpace QSFP28 2x100G breakout" + +# --- Juniper ACX7024 Platform-Specific Rules --- +# These rules are scoped to the ACX7024 device type and use bay_position +# to generate Juniper-style interface names with FPC/PIC/port notation. + +# 100GE QSFP28 transceivers -> et-0/0/{port} +- module_type: QSFP-100G-LR4 + device_type: ACX7024 + name_template: "et-0/0/{bay_position}" + channel_count: 0 + channel_start: 0 + description: "Juniper ACX7024 100GE QSFP28 naming" + +- module_type: QSFP-100G-SR4 + device_type: ACX7024 + name_template: "et-0/0/{bay_position}" + channel_count: 0 + channel_start: 0 + description: "Juniper ACX7024 100GE QSFP28 naming" + +- module_type: QSFP-100G-SWDM4 + device_type: ACX7024 + name_template: "et-0/0/{bay_position}" + channel_count: 0 + channel_start: 0 + description: "Juniper ACX7024 100GE QSFP28 naming" + +# 10GE SFP+ transceivers -> xe-0/0/{port} +- module_type: SFP-10G-SR + device_type: ACX7024 + name_template: "xe-0/0/{bay_position}" + channel_count: 0 + channel_start: 0 + description: "Juniper ACX7024 10GE SFP+ naming" + +- module_type: SFP-10G-LR + device_type: ACX7024 + name_template: "xe-0/0/{bay_position}" + channel_count: 0 + channel_start: 0 + description: "Juniper ACX7024 10GE SFP+ naming" + +# 1GE SFP transceivers -> ge-0/0/{port} +- module_type: SFP-1G-T + device_type: ACX7024 + name_template: "ge-0/0/{bay_position}" + channel_count: 0 + channel_start: 0 + description: "Juniper ACX7024 1GE SFP naming" + +- module_type: SFP-1G-LX + device_type: ACX7024 + name_template: "ge-0/0/{bay_position}" + channel_count: 0 + channel_start: 0 + description: "Juniper ACX7024 1GE SFP naming" diff --git a/contrib/interface_type_mappings.yaml b/contrib/interface_type_mappings.yaml new file mode 100644 index 0000000000..19db2a1fcf --- /dev/null +++ b/contrib/interface_type_mappings.yaml @@ -0,0 +1,70 @@ +# Interface Type Mappings +# +# Maps LibreNMS interface types (and optional speeds) to NetBox interface types. +# Import via: LibreNMS Plugin > Interface Mappings > Import +# +# Fields: +# librenms_type β€” IANA ifType string from LibreNMS (e.g. ethernetCsmacd) +# librenms_speed β€” Speed in Kbps (optional, null matches any speed) +# netbox_type β€” NetBox InterfaceTypeChoices slug +# description β€” Optional note +# +# Common NetBox interface type slugs: +# 1000base-t, 10gbase-t, 10gbase-x-sfpp, 25gbase-x-sfp28, +# 40gbase-x-qsfpp, 100gbase-x-qsfp28, 400gbase-x-qsfpdd, +# ieee802.11ax, lag, virtual, other + +- librenms_type: ethernetCsmacd + librenms_speed: 1000000 + netbox_type: 1000base-t + description: "1G Ethernet copper" + +- librenms_type: ethernetCsmacd + librenms_speed: 10000000 + netbox_type: 10gbase-x-sfpp + description: "10G Ethernet SFP+" + +- librenms_type: ethernetCsmacd + librenms_speed: 25000000 + netbox_type: 25gbase-x-sfp28 + description: "25G Ethernet SFP28" + +- librenms_type: ethernetCsmacd + librenms_speed: 40000000 + netbox_type: 40gbase-x-qsfpp + description: "40G Ethernet QSFP+" + +- librenms_type: ethernetCsmacd + librenms_speed: 100000000 + netbox_type: 100gbase-x-qsfp28 + description: "100G Ethernet QSFP28" + +- librenms_type: ethernetCsmacd + librenms_speed: 400000000 + netbox_type: 400gbase-x-qsfpdd + description: "400G Ethernet QSFP-DD" + +- librenms_type: ieee8023adLag + librenms_speed: + netbox_type: lag + description: "LACP/LAG aggregation" + +- librenms_type: propVirtual + librenms_speed: + netbox_type: virtual + description: "Virtual/loopback interface" + +- librenms_type: softwareLoopback + librenms_speed: + netbox_type: virtual + description: "Software loopback" + +- librenms_type: tunnel + librenms_speed: + netbox_type: virtual + description: "Tunnel interface" + +- librenms_type: l2vlan + librenms_speed: + netbox_type: virtual + description: "VLAN interface" diff --git a/contrib/module_bay_mappings.yaml b/contrib/module_bay_mappings.yaml new file mode 100644 index 0000000000..64c063176a --- /dev/null +++ b/contrib/module_bay_mappings.yaml @@ -0,0 +1,216 @@ +# Module Bay Mappings - Map LibreNMS inventory container names to NetBox module bay names +# +# These mappings replace heuristic matching between LibreNMS inventory and NetBox module bays. +# Import via: LibreNMS Plugin β†’ Module Bay Mappings β†’ Import +# +# Fields: +# librenms_name: LibreNMS entPhysicalName or container name (exact match or regex) +# librenms_class: Optional entPhysicalClass filter (powerSupply, fan, module, etc.) +# Leave empty for class-independent mappings +# netbox_bay_name: Target NetBox module bay name (supports \1, \2 backreferences with regex) +# is_regex: Set to true to treat librenms_name as a Python regex pattern +# description: Optional description +# +# Regex patterns use Python re.fullmatch() β€” the pattern must match the entire string. +# Backreferences (\1, \2) in netbox_bay_name reference capture groups in the pattern. + +# ─── Regex Patterns ────────────────────────────────────────────────────────── +# These patterns replace many individual exact-match entries. + +# Arcos/UfiSpace: sfpN β†’ Transceiver N (covers sfp0 through sfp53+) +- librenms_name: "^sfp(\\d+)$" + netbox_bay_name: "Transceiver \\1" + is_regex: true + description: "Arcos sfpN β†’ Transceiver N" + +# Cisco X2: Port Container slot/port β†’ X2 Port port +- librenms_name: "^Port Container (\\d+)/(\\d+)$" + netbox_bay_name: "X2 Port \\2" + is_regex: true + description: "Cisco X2 Port Container β†’ X2 Port N" + +# Cisco modules: Linecard/Supervisor(slot N) β†’ Slot N +- librenms_name: "^Linecard\\(slot (\\d+)\\)$" + librenms_class: "module" + netbox_bay_name: "Slot \\1" + is_regex: true + description: "Cisco Linecard slot β†’ Slot N" +- librenms_name: "^Supervisor\\(slot (\\d+)\\)$" + librenms_class: "module" + netbox_bay_name: "Slot \\1" + is_regex: true + description: "Cisco Supervisor slot β†’ Slot N" + +# Generic power supplies and fans +- librenms_name: "^Power Supply (\\d+)$" + librenms_class: "powerSupply" + netbox_bay_name: "PS\\1" + is_regex: true + description: "Power Supply N β†’ PSN" +- librenms_name: "^FanTray (\\d+)$" + librenms_class: "fan" + netbox_bay_name: "Fan Tray \\1" + is_regex: true + description: "FanTray N β†’ Fan Tray N" + +# Nokia 7750 SR chassis fans and power modules +- librenms_name: "^Chassis 1 Fan (\\d+)$" + librenms_class: "fan" + netbox_bay_name: "Fan \\1" + is_regex: true + description: "Nokia chassis fan β†’ Fan N" +- librenms_name: "^Chassis 1 PowShelf 1 PM (\\d+)$" + librenms_class: "powerSupply" + netbox_bay_name: "PM \\1" + is_regex: true + description: "Nokia power module β†’ PM N" + +# Nokia MDA and XIOM sub-module bays +# Bay names resolve from {module}/N templates: IOM Slot 1 pos=1 β†’ bay {module}/1 = 1/1 +- librenms_name: "^MDA (\\d+)/(\\d+)$" + librenms_class: "mdaModule" + netbox_bay_name: "\\1/\\2" + is_regex: true + description: "Nokia MDA N/M β†’ N/M (matches {module}/M on IOM)" +- librenms_name: "^XIOM (\\d+)/x(\\d+)$" + librenms_class: "xioModule" + netbox_bay_name: "\\1/x\\2" + is_regex: true + description: "Nokia XIOM N/xM β†’ N/xM (matches {module}/xM on IOM)" +- librenms_name: "^MDA (\\d+)/x(\\d+)/(\\d+)$" + librenms_class: "mdaModule" + netbox_bay_name: "x\\2/\\3" + is_regex: true + description: "Nokia MDA in XIOM N/xP/Q β†’ xP/Q (matches {module}/Q on XIOM)" + +# Nokia transceiver connector bays +# LibreNMS ifName "1/1/c1" (slot/mda/connector) β†’ NetBox bay "1/c1" +# ({module} on MDA resolves to position, stripping the slot prefix) +- librenms_name: "(\\d+)/(\\d+)/(c\\d+)" + librenms_class: "port" + netbox_bay_name: "\\2/\\3" + is_regex: true + description: "Nokia transceiver slot/mda/cN β†’ mda-pos/cN" +# LibreNMS ifName "2/x1/1/c2" (slot/xiom/mda/connector) β†’ NetBox bay "1/c2" +- librenms_name: "(\\d+)/x(\\d+)/(\\d+)/(c\\d+)" + librenms_class: "port" + netbox_bay_name: "\\3/\\4" + is_regex: true + description: "Nokia XIOM transceiver slot/xiom/mda/cN β†’ mda-pos/cN" + +# Juniper MX transceiver bays +# LibreNMS entPhysicalDescr format: "SFP+-10G-SR @ {fpc}/{pic}/{port}" +# NetBox MPC-3D-16XGE-SFPP bay format: "Transceiver {pic}/{port}" +- librenms_name: "[^@]+ @ \\d+/(\\d+)/(\\d+)" + librenms_class: "port" + netbox_bay_name: "Transceiver \\1/\\2" + is_regex: true + description: "Juniper MX SFP+ @ fpc/pic/port β†’ Transceiver pic/port" + +# ─── Exact Match Entries ───────────────────────────────────────────────────── +# These are for special cases where names don't follow a regex pattern. + +# Nokia CPM slots +- librenms_name: "Slot A" + librenms_class: "cpmModule" + netbox_bay_name: "Slot A" + description: "Nokia CPM slot A" +- librenms_name: "Slot B" + librenms_class: "cpmModule" + netbox_bay_name: "Slot B" + description: "Nokia CPM slot B" +- librenms_name: "SR-7s 2 CPM mini" + librenms_class: "cpmCarrier" + netbox_bay_name: "CMA" + description: "Nokia CMA2-7s CPM carrier bracket" + +# Juniper fixed-form devices +- librenms_name: "PSM 0" + librenms_class: "powerSupply" + netbox_bay_name: "PSU 0" + description: "Juniper PSU slot 0" +- librenms_name: "PSM 1" + librenms_class: "powerSupply" + netbox_bay_name: "PSU 1" + description: "Juniper PSU slot 1" + +# Juniper chassis devices (PTX10008 etc.): PSM β†’ PEM +# Regex runs after exact matches, so PSM 0/1 β†’ PSU 0/1 above takes priority for ACX +- librenms_name: "^PSM (\\d+)$" + librenms_class: "powerSupply" + netbox_bay_name: "PEM \\1" + is_regex: true + description: "Juniper chassis PSM N β†’ PEM N" + +# Juniper FPC container: "FPC: @ N/*/*" β†’ FPC N +- librenms_name: "^FPC: .+ @ (\\d+)/\\*/\\*$" + librenms_class: "container" + netbox_bay_name: "FPC \\1" + is_regex: true + description: "Juniper FPC container description β†’ FPC N" + +# Juniper transceivers: " @ slot/pic/port" description β†’ Transceiver slot/pic/port +- librenms_name: "^.+ @ (\\d+/\\d+/\\d+)$" + librenms_class: "port" + netbox_bay_name: "Transceiver \\1" + is_regex: true + description: "Juniper transceiver description β†’ Transceiver slot/pic/port" + +# Juniper fan trays: "Fan Tray N" β†’ "Fan N" (ACX7100, etc.) +# Runs after exact match, so "Fan Tray 0" β†’ "Fan Tray" (ACX7024) still works +- librenms_name: "^Fan Tray (\\d+)$" + librenms_class: "fan" + netbox_bay_name: "Fan \\1" + is_regex: true + description: "Juniper Fan Tray N β†’ Fan N (ACX7100 etc.)" + +# Juniper MX304: PEM β†’ PSU (MX304 bays are named PSU, not PEM) +- librenms_name: "PEM 0" + librenms_class: "powerSupply" + netbox_bay_name: "PSU 0" + description: "Juniper MX304 PEM 0 β†’ PSU 0" +- librenms_name: "PEM 1" + librenms_class: "powerSupply" + netbox_bay_name: "PSU 1" + description: "Juniper MX304 PEM 1 β†’ PSU 1" + +- librenms_name: "Fan Tray 0" + librenms_class: "fan" + netbox_bay_name: "Fan Tray" + description: "Juniper single fan tray (ACX7024)" + +# Juniper PTX10008: SIB β†’ CB (Switch Interface Board β†’ Component Board slot) +- librenms_name: "SIB 0" + librenms_class: "container" + netbox_bay_name: "CB 0" + description: "Juniper PTX10008 SIB 0 β†’ CB 0" +- librenms_name: "SIB 1" + librenms_class: "container" + netbox_bay_name: "CB 1" + description: "Juniper PTX10008 SIB 1 β†’ CB 1" +- librenms_name: "SIB 2" + librenms_class: "container" + netbox_bay_name: "CB 2" + description: "Juniper PTX10008 SIB 2 β†’ CB 2" +- librenms_name: "SIB 3" + librenms_class: "container" + netbox_bay_name: "CB 3" + description: "Juniper PTX10008 SIB 3 β†’ CB 3" +- librenms_name: "SIB 4" + librenms_class: "container" + netbox_bay_name: "CB 4" + description: "Juniper PTX10008 SIB 4 β†’ CB 4" +- librenms_name: "SIB 5" + librenms_class: "container" + netbox_bay_name: "CB 5" + description: "Juniper PTX10008 SIB 5 β†’ CB 5" + +# Arcos power supplies +- librenms_name: "psu0" + librenms_class: "powerSupply" + netbox_bay_name: "PSU 0" + description: "Arcos PSU slot 0" +- librenms_name: "psu1" + librenms_class: "powerSupply" + netbox_bay_name: "PSU 1" + description: "Arcos PSU slot 1" diff --git a/contrib/module_type_mappings.yaml b/contrib/module_type_mappings.yaml new file mode 100644 index 0000000000..e70d726f1b --- /dev/null +++ b/contrib/module_type_mappings.yaml @@ -0,0 +1,332 @@ +# Module Type Mappings +# +# Maps LibreNMS inventory model names (entPhysicalModelName) to NetBox module types. +# Import via: LibreNMS Plugin > Module Type Mappings > Import +# +# Fields: +# librenms_model β€” Model name from LibreNMS SNMP inventory +# netbox_module_type β€” NetBox ModuleType (matched by model name or ID) +# description β€” Optional note +# +# These mappings are checked FIRST. If no mapping exists, the plugin falls back +# to exact model name and part_number matching against NetBox module types. + +# ─── Cisco Catalyst 4900M ──────────────────────────────────────────────────── + +- librenms_model: "WS-X4908-10GE" + netbox_module_type: "WS-X4908-10GE" + description: "Cisco 8-port 10G X2 line card" + +- librenms_model: "WS-X4992" + netbox_module_type: "WS-X4992" + description: "Cisco 48-port 10/100/1000 line card" + +- librenms_model: "PWR-C49M-1000AC" + netbox_module_type: "PWR-C49M-1000AC" + description: "Cisco 1000W AC power supply" + +- librenms_model: "CVR-X2-SFP" + netbox_module_type: "CVR-X2-SFP" + description: "Cisco X2-to-SFP converter" + +# ─── Juniper Backplane ─────────────────────────────────────────────────────── + +- librenms_model: "710-017414" + netbox_module_type: "MX480-CHASSIS-BP" + description: "Juniper MX480 backplane (matched by part number)" + +- librenms_model: "CHAS-BP-MX480-S" + netbox_module_type: "MX480-CHASSIS-BP" + description: "Juniper MX480 backplane (matched by name)" + +# ─── Juniper FPC / Line Card Mappings ──────────────────────────────────────── +# Juniper FPCs use 750-xxxxxx part numbers as entPhysicalModelName. + +- librenms_model: "750-018124" + netbox_module_type: "DPCE-R-4XGE-XFP" + description: "Juniper DPCE 4-port 10G XFP DPC" + +- librenms_model: "750-022765" + netbox_module_type: "DPCE-R-20GE-2XGE" + description: "Juniper DPCE 20x1G + 2x10G combo DPC" + +- librenms_model: "750-028467" + netbox_module_type: "MPC-3D-16XGE-SFPP" + description: "Juniper MPC 16-port 10G SFP+" + +- librenms_model: "750-056519" + netbox_module_type: "MPC7E-MRATE" + description: "Juniper MPC7E 12-port QSFP+/QSFP28 multirate" + +- librenms_model: "750-062581" + netbox_module_type: "MPC-3D-16XGE-SFPP" + description: "Juniper MPC 16-port 10G SFP+ (variant PN)" + +# ─── Juniper Power Supply Mappings ─────────────────────────────────────────── + +- librenms_model: "740-029970" + netbox_module_type: "PWR-MX480-2520-AC" + description: "Juniper MX480 2520W AC PSU" + +- librenms_model: "740-063046" + netbox_module_type: "PWR-MX480-2520-AC" + description: "Juniper MX480 2520W AC PSU (variant PN)" + +- librenms_model: "740-027760" + netbox_module_type: "PWR-MX960-4100-AC" + description: "Juniper MX960 4100W AC PSU" + +- librenms_model: "740-110419" + netbox_module_type: "JNP-PWR2200-AC" + description: "Juniper MX304 2200W AC PSU" + +# Removed: JPSU-1600W-1UACAFO β€” exact model match, no mapping needed + +# ─── Juniper Fan Tray Mappings ─────────────────────────────────────────────── + +- librenms_model: "740-031521" + netbox_module_type: "FFANTRAY-MX960-HC" + description: "Juniper MX960 high-capacity fan tray" + +- librenms_model: "760-126744" + netbox_module_type: "JNP-FAN-2RU" + description: "Juniper MX304 2RU fan tray" + +# Removed: JNP7100-FAN1RU-AO β€” exact model match, no mapping needed + +# ─── Nokia 7750 SR-7s Module Mappings ──────────────────────────────────────── +# Nokia 3HE part numbers are handled by NormalizationRule: +# 1. Strip extra text (e.g. "3HE10550AARA01 NOK IPU3BFUEAA" β†’ "3HE10550AARA01") +# 2. Strip revision suffix (e.g. "3HE10550AARA01" β†’ "3HE10550AA") +# The normalized value matches the part_number field on NetBox ModuleTypes. +# No explicit Nokia mappings are needed. + +# ─── Transceiver Mappings: Juniper Part Numbers ───────────────────────────── +# Juniper-qualified optics use 740-xxxxxx part numbers regardless of OEM vendor. + +- librenms_model: "740-013111" + netbox_module_type: "SFP-1G-T" + description: "Juniper SFP 1000BASE-T copper" + +- librenms_model: "740-021308" + netbox_module_type: "SFP-10G-SR" + description: "Juniper SFP+ 10G-SR" + +- librenms_model: "740-031850" + netbox_module_type: "SFP-1G-LX" + description: "Juniper SFP 1000BASE-LX 10km" + +- librenms_model: "740-031980" + netbox_module_type: "SFP-10G-SR" + description: "Juniper SFP+ 10G-SR" + +- librenms_model: "740-031981" + netbox_module_type: "SFP-10G-LR" + description: "Juniper SFP+ 10G-LR" + +- librenms_model: "740-047682" + netbox_module_type: "CFP-100G-LR4" + description: "Juniper CFP 100G-LR4" + +- librenms_model: "740-054050" + netbox_module_type: "QSFP-4X10G-LR" + description: "Juniper QSFP+ 4x10G-LR" + +- librenms_model: "740-054053" + netbox_module_type: "QSFP-4X10G-SR" + description: "Juniper QSFP+ 4x10G-SR" + +- librenms_model: "740-058732" + netbox_module_type: "QSFP-100G-LR4" + description: "Juniper QSFP28 100G-LR4" + +- librenms_model: "740-061405" + netbox_module_type: "QSFP-100G-SR4" + description: "Juniper QSFP28 100G-SR4" + +- librenms_model: "740-061409" + netbox_module_type: "QSFP-100G-LR4" + description: "Juniper QSFP28 100G-LR4" + +- librenms_model: "740-079871" + netbox_module_type: "QSFP28-DD-2X100G-LR4" + description: "Juniper QSFP-DD 2x100G-LR4" + +- librenms_model: "740-082823" + netbox_module_type: "QSFP-DD-400G-LR8" + description: "Juniper QSFP-DD 400G-LR8" + +- librenms_model: "740-085349" + netbox_module_type: "QSFP-DD-400G-FR4" + description: "Juniper QSFP-DD 400G-FR4" + +- librenms_model: "740-085351" + netbox_module_type: "QSFP-DD-400G-DR4" + description: "Juniper QSFP-DD 400G-DR4" + +- librenms_model: "740-096176" + netbox_module_type: "QSFP-DD-400G-LR4" + description: "Juniper QSFP-DD 400G-LR4 (10km variant)" + +- librenms_model: "740-131169" + netbox_module_type: "QSFP-DD-400G-ZR-M" + description: "Juniper QSFP-DD 400G-ZR-M" + +- librenms_model: "740-151745" + netbox_module_type: "QSFP-DD-400G-ZR-M-HP" + description: "Juniper QSFP-DD 400G-ZR-M high-power" + +- librenms_model: "740-172665" + netbox_module_type: "QSFP-100G-ZR" + description: "Juniper QSFP28 100G-ZR" + +# ─── Transceiver Mappings: Finisar / II-VI / Coherent ──────────────────────── +# These are BASE part numbers (after normalization strips customer suffixes). +# See contrib/normalization_rules.yaml for the Finisar suffix-stripping rule. + +- librenms_model: "FTLC1154RDPL" + netbox_module_type: "QSFP-100G-LR4" + description: "Finisar QSFP28 100G-LR4" + +- librenms_model: "FTLC1151RDPL" + netbox_module_type: "QSFP-100G-LR4" + description: "Finisar QSFP28 100G-LR4 (variant)" + +- librenms_model: "FTLX1474D3BCL" + netbox_module_type: "SFP-10G-LR" + description: "Finisar SFP+ 10G-LR" + +- librenms_model: "FTCD3323R1PCL" + netbox_module_type: "QSFP-DD-400G-ZR-M" + description: "Finisar/II-VI QSFP-DD 400G-ZR-M coherent" + +- librenms_model: "FTLC9152RGPL" + netbox_module_type: "QSFP-100G-SWDM4" + description: "Finisar QSFP28 100G-SWDM4" + +# ─── Transceiver Mappings: Cisco / Cisco-branded OEM ───────────────────────── + +- librenms_model: "X2-10GB-LR" + netbox_module_type: "X2-10GB-LR" + description: "Cisco X2 10G-LR" + +- librenms_model: "X2-10GB-SR" + netbox_module_type: "X2-10GB-SR" + description: "Cisco X2 10G-SR" + +- librenms_model: "GLC-T" + netbox_module_type: "SFP-1G-T" + description: "Cisco SFP 1000BASE-T copper" + +- librenms_model: "GLC-TE" + netbox_module_type: "SFP-1G-T" + description: "Cisco SFP 1000BASE-T copper (extended temp)" + +- librenms_model: "SPP5200LR-C5" + netbox_module_type: "SFP-10G-LR" + description: "Cisco-branded Sumitomo SFP+ 10G-LR" + +- librenms_model: "SPP5310LR-C5" + netbox_module_type: "SFP-10G-LR" + description: "Cisco-branded Sumitomo SFP+ 10G-LR" + +- librenms_model: "SFBR-709SMZ-CS1" + netbox_module_type: "SFP-10G-SR" + description: "Cisco-branded Avago/Broadcom SFP+ 10G-SR" + +- librenms_model: "DP04QSDD-HE0" + netbox_module_type: "QSFP-DD-400G-ZR+" + description: "Cisco/Acacia QSFP-DD 400G-ZR+ coherent" + +- librenms_model: "QDD-400G-ZRP-S" + netbox_module_type: "QSFP-DD-400G-ZR+" + description: "Cisco QSFP-DD 400G-ZR+" + +- librenms_model: "QDD-400G-ZR4-S" + netbox_module_type: "QSFP-DD-400G-ZR" + description: "Cisco QSFP-DD 400G-ZR" + +# ─── Transceiver Mappings: Ciena ───────────────────────────────────────────── + +- librenms_model: "180-3530-900" + netbox_module_type: "QSFP-DD-400G-ZR" + description: "Ciena WaveLogic 5 Nano QSFP-DD 400ZR" + +- librenms_model: "176-3360-900" + netbox_module_type: "QSFP-DD-400G-ZR-M" + description: "Ciena QSFP-DD 400G-ZR-M coherent" + +- librenms_model: "176-3530-901" + netbox_module_type: "QSFP-DD-400G-ZR" + description: "Ciena QSFP-DD 400G-ZR coherent" + +- librenms_model: "176-3590-900" + netbox_module_type: "QSFP-DD-400G-ZR-M" + description: "Ciena QSFP-DD 400G-ZR-M coherent" + +# ─── Transceiver Mappings: T1 Nexus ───────────────────────────────────────── + +- librenms_model: "T1-QDD-400G-LR4" + netbox_module_type: "QSFP-DD-400G-LR4" + description: "T1 Nexus QSFP-DD 400G-LR4" + +- librenms_model: "T1-QDD-400G-FR4" + netbox_module_type: "QSFP-DD-400G-FR4" + description: "T1 Nexus QSFP-DD 400G-FR4" + +- librenms_model: "T1-QSFP28-LR4" + netbox_module_type: "QSFP-100G-LR4" + description: "T1 Nexus QSFP28 100G-LR4" + +- librenms_model: "100G-LR4_A3" + netbox_module_type: "QSFP-100G-LR4" + description: "T1 Nexus QSFP28 100G-LR4 (rev A3)" + +# ─── Transceiver Mappings: Innolight ──────────────────────────────────────── + +- librenms_model: "T-DQ4CNT-NCN" + netbox_module_type: "QSFP-DD-400G-FR4" + description: "Innolight QSFP-DD 400G-FR4" + +# ─── Transceiver Mappings: FS.com ──────────────────────────────────────────── + +- librenms_model: "Q28-PC03" + netbox_module_type: "QSFP28-100G-CU3M" + description: "FS.com QSFP28 100G passive DAC 3m" + +# ─── Transceiver Mappings: ProLabs ─────────────────────────────────────────── + +- librenms_model: "Q28LR431-10-IN" + netbox_module_type: "QSFP-100G-LR4" + description: "ProLabs QSFP28 100G-LR4 10km" + +# ─── Transceiver Mappings: Arcos Fixed-Port Part Numbers ───────────────────── + +- librenms_model: "SP7041-TE" + netbox_module_type: "SFP-1G-T" + description: "SFP 1000BASE-T copper (Arcos platform)" + +# ─── Transceiver Mappings: LeGrand Innolight ───────────────────────────────── + +- librenms_model: "LGI-FTLC9152RGPL" + netbox_module_type: "QSFP-100G-SWDM4" + description: "LeGrand-branded Finisar QSFP28 100G-SWDM4" + +# ─── Transceiver Mappings: Additional Finisar Variants ────────────────────── +# Some transceivers have customer-code suffixes that normalization may not handle. +# Add direct mappings as fallback. + +- librenms_model: "FTLC1151RDPL-CN" + netbox_module_type: "QSFP-100G-LR4" + description: "Finisar QSFP28 100G-LR4 (CN customer code)" + +- librenms_model: "FTLC1154RDPL-A5" + netbox_module_type: "QSFP-100G-LR4" + description: "Finisar QSFP28 100G-LR4 (A5 customer code)" + +# ─── Unknown / Unidentified Part Numbers ───────────────────────────────────── +# These are mapped based on port context (QSFP28 100G slot) when vendor is unknown. + +- librenms_model: "1F3QAA" + netbox_module_type: "QSFP-100G-LR4" + description: "Unknown QSFP28 100G (mapped by port context)" diff --git a/contrib/normalization_rules.yaml b/contrib/normalization_rules.yaml new file mode 100644 index 0000000000..c3d2081bea --- /dev/null +++ b/contrib/normalization_rules.yaml @@ -0,0 +1,61 @@ +# Normalization Rules β€” Examples +# +# Regex-based string transformations applied before module type, device type, +# or module bay matching. Rules run in priority order (lower first); each +# rule's output feeds the next. +# +# Import via: LibreNMS β†’ Normalization Rules β†’ Import β†’ YAML +# +# Fields: +# scope β€” module_type, device_type, or module_bay +# manufacturer β€” Optional manufacturer name (must exist in NetBox). +# When set, the rule only fires for that manufacturer. +# match_pattern β€” Python regex (re.sub pattern) +# replacement β€” Replacement string (supports \1, \2 back-references) +# priority β€” Lower values run first (default 100) +# description β€” Optional note + +# ── Nokia revision suffix stripping ────────────────────────────────────────── +# Nokia ENTITY-MIB reports module/transceiver models with 4-char revision +# suffixes (e.g. 3HE16474AARA01). NetBox module types use the base part +# number (3HE16474AA). This rule strips the suffix before matching. +# +# Captures the 10-char base (3HE + 5 alnum + 2 quality-tier letters), +# discards the 2-letter revision code + 2-digit build number. +- scope: module_type + manufacturer: Nokia + match_pattern: "^(3HE\\w{5}[A-Z]{2})[A-Z]{2}\\d{2}$" + replacement: "\\1" + priority: 100 + description: "Strip Nokia revision suffixes (e.g. RA01, RB01, RG01) from ENTITY-MIB model strings" + +# ── Finisar / II-VI / Coherent suffix stripping ───────────────────────────── +# Finisar part numbers have customer-specific suffixes after a hyphen: +# FTLC1154RDPL-A5 (original Finisar) +# FTLC1154RDPL-C (Prolabs compatible) +# FTLX1474D3BCL-C1 (Cisco-coded Finisar) +# This rule strips everything after the last hyphen for FT... models. +- scope: module_type + match_pattern: "^(FT[A-Z0-9]+)-[A-Z0-9]+$" + replacement: "\\1" + priority: 100 + description: "Strip Finisar/II-VI customer suffixes (-A5, -C, -CN, -C1, etc.)" + +# ── Prolabs LGI- prefix stripping ─────────────────────────────────────────── +# Prolabs-compatible optics sometimes prepend LGI- to the OEM part number: +# LGI-FTLC9152RGPL β†’ FTLC9152RGPL +- scope: module_type + match_pattern: "^LGI-(.+)$" + replacement: "\\1" + priority: 50 + description: "Strip Prolabs LGI- prefix from OEM part numbers" + +# ── Nokia transceiver model field cleanup ──────────────────────────────────── +# Nokia transceiver API sometimes returns model strings with trailing vendor +# info: "3HE10550AARA01 NOK IPU3BFUEAA" β€” extract just the part number. +- scope: module_type + manufacturer: Nokia + match_pattern: "^(3HE\\w+)\\s+.*$" + replacement: "\\1" + priority: 50 + description: "Extract Nokia part number from transceiver model field (strip trailing vendor/oui info)" diff --git a/docs/usage_tips/custom_field.md b/docs/usage_tips/custom_field.md index 032812ed82..7ed27a2f97 100644 --- a/docs/usage_tips/custom_field.md +++ b/docs/usage_tips/custom_field.md @@ -4,6 +4,9 @@ To enhance device identification and synchronization between NetBox and LibreNMS, this plugin supports using a custom field `librenms_id` on Device, Virtual Machine and Interface objects. While the plugin works without it, using this custom field is recommended for LibreNMS API lookups, and to assist with matching the remote device and remote interfaces for cable creation in Netbox. It can also be entered manually if no primary IP or FQDN is available. +!!! info "Automatic Creation" + As of version 0.4.2, the plugin **automatically creates** the `librenms_id` custom field when migrations are run. You no longer need to create it manually. The field is created for Device, Virtual Machine, Interface, and VM Interface objects. + For the Device and Virtual Machine objects the plugin will automatically populate the LibreNMS ID custom field when opening the LibreNMS Sync page if the device has been found in LibreNMS. For the Interface object, the plugin will automatically populate the LibreNMS ID custom field when the interface data is synced from LibreNMS. @@ -15,7 +18,10 @@ For the Interface object, the plugin will automatically populate the LibreNMS ID - **Efficient Synchronization:** Enhances the reliability of API lookups. - **Cable creation:** Allows better device identification for the creation of cables between NetBox devices. -## Suggested Custom Field Setup +## Manual Custom Field Setup (Legacy) + +!!! note + This section is only needed if you are running an older version of the plugin that does not auto-create the field, or if you need to recreate it after deletion. Follow these steps to create the `librenms_id` custom field in NetBox: diff --git a/docs/usage_tips/permissions.md b/docs/usage_tips/permissions.md index 9f9ecfb3f3..39c5225d2a 100644 --- a/docs/usage_tips/permissions.md +++ b/docs/usage_tips/permissions.md @@ -26,7 +26,7 @@ A user needs both tiers of permissions to complete an action. For example, to vi The Plugin also enforces Netbox object permissions so the following permission would also be required: -2. **Tier 2: Object permission**: User needs `dcim.add_device` (to create the device in NetBox) +1. **Tier 2: Object permission**: User needs `dcim.add_device` (to create the device in NetBox) If either permission is missing, the operation fails with an appropriate error message. diff --git a/netbox_librenms_plugin/__init__.py b/netbox_librenms_plugin/__init__.py index f1720c85d6..206a3009c8 100644 --- a/netbox_librenms_plugin/__init__.py +++ b/netbox_librenms_plugin/__init__.py @@ -28,6 +28,7 @@ def ready(self): super().ready() from django.conf import settings + from django.db.models.signals import post_migrate plugin_config = getattr(settings, "PLUGINS_CONFIG", {}).get(self.name, {}) @@ -37,6 +38,12 @@ def ready(self): else: self._validate_legacy_config(plugin_config) + # Auto-create the librenms_id custom field after migrations complete + post_migrate.connect( + _ensure_librenms_id_custom_field, + dispatch_uid="netbox_librenms_plugin_ensure_cf", + ) + def _validate_multi_server_config(self, servers_config): """Validate multi-server configuration.""" if not servers_config or not isinstance(servers_config, dict): @@ -61,4 +68,72 @@ def _validate_legacy_config(self, plugin_config): ) +def _ensure_librenms_id_custom_field(sender, **kwargs): + """ + Auto-create (or migrate) the 'librenms_id' custom field. + Runs after migrations via post_migrate signal to ensure tables exist. + Uses dispatch_uid to avoid duplicate connections. + + librenms_id stores a per-server JSON mapping {"server_key": device_id}. + Legacy installations may have this field typed as 'integer'; we upgrade it + to 'json' automatically so the UI and API accept the dict format. + """ + # Only run once per migrate invocation (post_migrate fires per-app). + if getattr(_ensure_librenms_id_custom_field, "_executed", False): + return + + import logging + + try: + from django.contrib.contenttypes.models import ContentType + + from extras.models import CustomField + + cf, created = CustomField.objects.get_or_create( + name="librenms_id", + defaults={ + "type": "json", + "label": "LibreNMS ID", + "description": "LibreNMS Device ID for synchronization (auto-created by plugin)", + "required": False, + "ui_visible": "if-set", + "ui_editable": "yes", + "is_cloneable": False, + }, + ) + + # Migrate legacy integer-typed field to JSON so the multi-server + # dict format {"server_key": device_id} is accepted by the UI/API. + if not created and cf.type == "integer": + cf.type = "json" + cf.save(update_fields=["type"]) + logging.getLogger("netbox_librenms_plugin").info( + "Migrated 'librenms_id' custom field type from integer to json" + ) + + # Ensure the field is assigned to the required object types + from dcim.models import Device, Interface + from virtualization.models import VirtualMachine, VMInterface + + required_models = [Device, VirtualMachine, Interface, VMInterface] + current_types = set(cf.object_types.values_list("pk", flat=True)) + + for model in required_models: + ct = ContentType.objects.get_for_model(model) + if ct.pk not in current_types: + cf.object_types.add(ct) + + if created: + logging.getLogger("netbox_librenms_plugin").info( + "Auto-created 'librenms_id' custom field for Device, VirtualMachine, Interface, VMInterface" + ) + + # Only mark as executed after successful completion to allow retry on failure. + _ensure_librenms_id_custom_field._executed = True + except Exception as e: + # Don't break startup if custom field creation fails (e.g., during initial migration), + # but log the error so it's not silently swallowed. + logging.getLogger("netbox_librenms_plugin").exception("Failed to auto-create 'librenms_id' custom field: %s", e) + + config = LibreNMSSyncConfig diff --git a/netbox_librenms_plugin/api/serializers.py b/netbox_librenms_plugin/api/serializers.py index 6bcd0aef20..bcde788d2b 100644 --- a/netbox_librenms_plugin/api/serializers.py +++ b/netbox_librenms_plugin/api/serializers.py @@ -1,6 +1,12 @@ from netbox.api.serializers import NetBoxModelSerializer -from netbox_librenms_plugin.models import InterfaceTypeMapping +from netbox_librenms_plugin.models import ( + DeviceTypeMapping, + InterfaceTypeMapping, + ModuleBayMapping, + ModuleTypeMapping, + NormalizationRule, +) class InterfaceTypeMappingSerializer(NetBoxModelSerializer): @@ -11,3 +17,51 @@ class Meta: model = InterfaceTypeMapping fields = ["id", "librenms_type", "librenms_speed", "netbox_type", "description"] + + +class DeviceTypeMappingSerializer(NetBoxModelSerializer): + """Serialize DeviceTypeMapping model for REST API.""" + + class Meta: + """Meta options for DeviceTypeMappingSerializer.""" + + model = DeviceTypeMapping + fields = ["id", "librenms_hardware", "netbox_device_type", "description"] + + +class ModuleTypeMappingSerializer(NetBoxModelSerializer): + """Serialize ModuleTypeMapping model for REST API.""" + + class Meta: + """Meta options for ModuleTypeMappingSerializer.""" + + model = ModuleTypeMapping + fields = ["id", "librenms_model", "netbox_module_type", "description"] + + +class ModuleBayMappingSerializer(NetBoxModelSerializer): + """Serialize ModuleBayMapping model for REST API.""" + + class Meta: + """Meta options for ModuleBayMappingSerializer.""" + + model = ModuleBayMapping + fields = ["id", "librenms_name", "librenms_class", "netbox_bay_name", "is_regex", "description"] + + +class NormalizationRuleSerializer(NetBoxModelSerializer): + """Serialize NormalizationRule model for REST API.""" + + class Meta: + """Meta options for NormalizationRuleSerializer.""" + + model = NormalizationRule + fields = [ + "id", + "scope", + "manufacturer", + "match_pattern", + "replacement", + "priority", + "description", + ] diff --git a/netbox_librenms_plugin/api/urls.py b/netbox_librenms_plugin/api/urls.py index 230aa078d0..c032e7b2f5 100644 --- a/netbox_librenms_plugin/api/urls.py +++ b/netbox_librenms_plugin/api/urls.py @@ -7,6 +7,10 @@ router = NetBoxRouter() router.register("interface-type-mappings", views.InterfaceTypeMappingViewSet) +router.register("device-type-mappings", views.DeviceTypeMappingViewSet) +router.register("module-type-mappings", views.ModuleTypeMappingViewSet) +router.register("module-bay-mappings", views.ModuleBayMappingViewSet) +router.register("normalization-rules", views.NormalizationRuleViewSet) urlpatterns = [ path("jobs//sync-status/", views.sync_job_status, name="sync_job_status"), diff --git a/netbox_librenms_plugin/api/views.py b/netbox_librenms_plugin/api/views.py index 768c67f5fe..287a3858c1 100644 --- a/netbox_librenms_plugin/api/views.py +++ b/netbox_librenms_plugin/api/views.py @@ -11,9 +11,21 @@ from rq.job import Job as RQJob from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN, PERM_VIEW_PLUGIN -from netbox_librenms_plugin.models import InterfaceTypeMapping - -from .serializers import InterfaceTypeMappingSerializer +from netbox_librenms_plugin.models import ( + DeviceTypeMapping, + InterfaceTypeMapping, + ModuleBayMapping, + ModuleTypeMapping, + NormalizationRule, +) + +from .serializers import ( + DeviceTypeMappingSerializer, + InterfaceTypeMappingSerializer, + ModuleBayMappingSerializer, + ModuleTypeMappingSerializer, + NormalizationRuleSerializer, +) logger = logging.getLogger(__name__) @@ -22,8 +34,8 @@ class LibreNMSPluginPermission(BasePermission): """ Permission class for LibreNMS plugin API endpoints. - - GET requests require view_librenmssettings - - All other requests require change_librenmssettings + - Safe requests (GET, HEAD, OPTIONS) require netbox_librenms_plugin.view_librenmssettings + - All other requests require netbox_librenms_plugin.change_librenmssettings """ def has_permission(self, request, view): @@ -41,6 +53,42 @@ class InterfaceTypeMappingViewSet(NetBoxModelViewSet): serializer_class = InterfaceTypeMappingSerializer +class DeviceTypeMappingViewSet(NetBoxModelViewSet): + """API viewset for DeviceTypeMapping CRUD operations.""" + + permission_classes = [LibreNMSPluginPermission] + + queryset = DeviceTypeMapping.objects.all() + serializer_class = DeviceTypeMappingSerializer + + +class ModuleTypeMappingViewSet(NetBoxModelViewSet): + """API viewset for ModuleTypeMapping CRUD operations.""" + + permission_classes = [LibreNMSPluginPermission] + + queryset = ModuleTypeMapping.objects.all() + serializer_class = ModuleTypeMappingSerializer + + +class ModuleBayMappingViewSet(NetBoxModelViewSet): + """API viewset for ModuleBayMapping CRUD operations.""" + + permission_classes = [LibreNMSPluginPermission] + + queryset = ModuleBayMapping.objects.all() + serializer_class = ModuleBayMappingSerializer + + +class NormalizationRuleViewSet(NetBoxModelViewSet): + """API viewset for NormalizationRule CRUD operations.""" + + permission_classes = [LibreNMSPluginPermission] + + queryset = NormalizationRule.objects.all() + serializer_class = NormalizationRuleSerializer + + @api_view(["POST"]) @permission_classes([LibreNMSPluginPermission]) def sync_job_status(request, job_pk): diff --git a/netbox_librenms_plugin/filters.py b/netbox_librenms_plugin/filters.py index 9ec162a64c..134bd8962d 100644 --- a/netbox_librenms_plugin/filters.py +++ b/netbox_librenms_plugin/filters.py @@ -1,6 +1,6 @@ import django_filters -from .models import InterfaceTypeMapping +from .models import DeviceTypeMapping, InterfaceTypeMapping, ModuleBayMapping, ModuleTypeMapping, NormalizationRule class InterfaceTypeMappingFilterSet(django_filters.FilterSet): @@ -11,3 +11,43 @@ class Meta: model = InterfaceTypeMapping fields = ["librenms_type", "librenms_speed", "netbox_type", "description"] + + +class DeviceTypeMappingFilterSet(django_filters.FilterSet): + """Filter set for DeviceTypeMapping model.""" + + class Meta: + """Meta options for DeviceTypeMappingFilterSet.""" + + model = DeviceTypeMapping + fields = ["librenms_hardware", "description"] + + +class ModuleTypeMappingFilterSet(django_filters.FilterSet): + """Filter set for ModuleTypeMapping model.""" + + class Meta: + """Meta options for ModuleTypeMappingFilterSet.""" + + model = ModuleTypeMapping + fields = ["librenms_model", "description"] + + +class ModuleBayMappingFilterSet(django_filters.FilterSet): + """Filter set for ModuleBayMapping model.""" + + class Meta: + """Meta options for ModuleBayMappingFilterSet.""" + + model = ModuleBayMapping + fields = ["librenms_name", "librenms_class", "netbox_bay_name", "is_regex"] + + +class NormalizationRuleFilterSet(django_filters.FilterSet): + """Filter set for NormalizationRule model.""" + + class Meta: + """Meta options for NormalizationRuleFilterSet.""" + + model = NormalizationRule + fields = ["scope", "manufacturer"] diff --git a/netbox_librenms_plugin/forms.py b/netbox_librenms_plugin/forms.py index f3e3d075e5..9c0bc7d9dc 100644 --- a/netbox_librenms_plugin/forms.py +++ b/netbox_librenms_plugin/forms.py @@ -2,7 +2,7 @@ import logging from dcim.choices import InterfaceTypeChoices -from dcim.models import Device, DeviceRole, DeviceType, Location, Rack, Site +from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, ModuleType, Rack, Site from django import forms from django.http import QueryDict from django.utils.translation import gettext_lazy as _ @@ -12,10 +12,22 @@ NetBoxModelImportForm, ) from netbox.plugins import get_plugin_config -from utilities.forms.fields import CSVChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.fields import ( + CSVChoiceField, + CSVModelChoiceField, + DynamicModelChoiceField, + DynamicModelMultipleChoiceField, +) from virtualization.models import Cluster, VirtualMachine -from .models import InterfaceTypeMapping, LibreNMSSettings +from .models import ( + DeviceTypeMapping, + InterfaceTypeMapping, + LibreNMSSettings, + ModuleBayMapping, + ModuleTypeMapping, + NormalizationRule, +) logger = logging.getLogger(__name__) @@ -51,11 +63,24 @@ def _get_librenms_poller_group_choices(): """ Helper function to get poller group choices from LibreNMS API. Shared between AddToLIbreSNMPV1V2 and AddToLIbreSNMPV3 forms. + Results are cached to avoid repeated API calls on every form instantiation. """ + from django.core.cache import cache + from .librenms_api import LibreNMSAPI choices = [("0", "Default (0)")] + try: + api = LibreNMSAPI() + server_id = api.librenms_url.rstrip("/") + cache_key = f"librenms_poller_group_choices_{server_id}" + except Exception: + cache_key = "librenms_poller_group_choices" + cached_choices = cache.get(cache_key) + if cached_choices: + return cached_choices + try: api = LibreNMSAPI() success, poller_groups = api.get_poller_groups() @@ -72,6 +97,8 @@ def _get_librenms_poller_group_choices(): else: label = f"{group_name} ({group_id})" choices.append((group_id, label)) + + cache.set(cache_key, choices, timeout=api.cache_timeout) except Exception: logger.exception("Failed to fetch LibreNMS poller groups; using default choices") @@ -90,10 +117,13 @@ class ServerConfigForm(NetBoxModelForm): ) class Meta: + """Meta options for ServerConfigForm.""" + model = LibreNMSSettings fields = ["selected_server"] def __init__(self, *args, **kwargs): + """Initialize form and populate server choices.""" super().__init__(*args, **kwargs) self.fields["selected_server"].choices = _get_librenms_server_choices() @@ -131,6 +161,8 @@ class ImportSettingsForm(NetBoxModelForm): ) class Meta: + """Meta options for ImportSettingsForm.""" + model = LibreNMSSettings fields = [ "vc_member_name_pattern", @@ -213,6 +245,8 @@ class InterfaceTypeMappingForm(NetBoxModelForm): """ class Meta: + """Meta options for InterfaceTypeMappingForm.""" + model = InterfaceTypeMapping fields = ["librenms_type", "librenms_speed", "netbox_type", "description"] @@ -230,6 +264,8 @@ class InterfaceTypeMappingImportForm(NetBoxModelImportForm): ) class Meta: + """Meta options for InterfaceTypeMappingImportForm.""" + model = InterfaceTypeMapping fields = ["librenms_type", "librenms_speed", "netbox_type", "description"] @@ -260,6 +296,175 @@ class InterfaceTypeMappingFilterForm(NetBoxModelFilterSetForm): model = InterfaceTypeMapping +class DeviceTypeMappingForm(NetBoxModelForm): + """Form for creating and editing device type mappings between LibreNMS and NetBox.""" + + netbox_device_type = forms.ModelChoiceField( + queryset=DeviceType.objects.all(), + label="NetBox Device Type", + widget=forms.Select(attrs={"class": "form-select"}), + ) + + class Meta: + """Meta options for DeviceTypeMappingForm.""" + + model = DeviceTypeMapping + fields = ["librenms_hardware", "netbox_device_type", "description"] + + +class DeviceTypeMappingImportForm(NetBoxModelImportForm): + """Form for bulk importing device type mappings.""" + + netbox_device_type = CSVModelChoiceField( + queryset=DeviceType.objects.all(), + to_field_name="model", + help_text="NetBox device type model name", + ) + + class Meta: + """Meta options for DeviceTypeMappingImportForm.""" + + model = DeviceTypeMapping + fields = ["librenms_hardware", "netbox_device_type", "description"] + + +class DeviceTypeMappingFilterForm(NetBoxModelFilterSetForm): + """Form for filtering device type mappings.""" + + librenms_hardware = forms.CharField(required=False, label="LibreNMS Hardware") + description = forms.CharField( + required=False, + label="Description", + help_text="Filter by description (partial match)", + ) + + model = DeviceTypeMapping + + +class ModuleTypeMappingForm(NetBoxModelForm): + """Form for creating and editing module type mappings between LibreNMS and NetBox.""" + + class Meta: + """Meta options for ModuleTypeMappingForm.""" + + model = ModuleTypeMapping + fields = ["librenms_model", "netbox_module_type", "description"] + + +class ModuleTypeMappingImportForm(NetBoxModelImportForm): + """Form for bulk importing module type mappings.""" + + netbox_module_type = CSVModelChoiceField( + queryset=ModuleType.objects.all(), + to_field_name="model", + help_text="NetBox module type model name", + ) + + class Meta: + """Meta options for ModuleTypeMappingImportForm.""" + + model = ModuleTypeMapping + fields = ["librenms_model", "netbox_module_type", "description"] + + +class ModuleTypeMappingFilterForm(NetBoxModelFilterSetForm): + """Form for filtering module type mappings.""" + + librenms_model = forms.CharField(required=False, label="LibreNMS Model") + description = forms.CharField( + required=False, + label="Description", + help_text="Filter by description (partial match)", + ) + + model = ModuleTypeMapping + + +class ModuleBayMappingForm(NetBoxModelForm): + """Form for creating and editing module bay mappings between LibreNMS and NetBox.""" + + class Meta: + """Meta options for ModuleBayMappingForm.""" + + model = ModuleBayMapping + fields = ["librenms_name", "librenms_class", "netbox_bay_name", "is_regex", "description"] + + +class ModuleBayMappingImportForm(NetBoxModelImportForm): + """Form for bulk importing module bay mappings.""" + + class Meta: + """Meta options for ModuleBayMappingImportForm.""" + + model = ModuleBayMapping + fields = ["librenms_name", "librenms_class", "netbox_bay_name", "is_regex", "description"] + + +class ModuleBayMappingFilterForm(NetBoxModelFilterSetForm): + """Form for filtering module bay mappings.""" + + librenms_name = forms.CharField(required=False, label="LibreNMS Name") + librenms_class = forms.CharField(required=False, label="LibreNMS Class") + netbox_bay_name = forms.CharField(required=False, label="NetBox Bay Name") + is_regex = forms.NullBooleanField(required=False, label="Regex") + + model = ModuleBayMapping + + +class NormalizationRuleForm(NetBoxModelForm): + """Form for creating and editing normalization rules.""" + + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + help_text="Optional: scope this rule to a specific manufacturer", + ) + + class Meta: + """Meta options for NormalizationRuleForm.""" + + model = NormalizationRule + fields = ["scope", "manufacturer", "match_pattern", "replacement", "priority", "description"] + + +class NormalizationRuleImportForm(NetBoxModelImportForm): + """Form for bulk importing normalization rules.""" + + scope = CSVChoiceField( + choices=NormalizationRule.SCOPE_CHOICES, + help_text="Scope: module_type, device_type, or module_bay", + ) + manufacturer = CSVModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name="name", + required=False, + help_text="Optional manufacturer name (must already exist in NetBox)", + ) + + class Meta: + """Meta options for NormalizationRuleImportForm.""" + + model = NormalizationRule + fields = ["scope", "manufacturer", "match_pattern", "replacement", "priority", "description"] + + +class NormalizationRuleFilterForm(NetBoxModelFilterSetForm): + """Form for filtering normalization rules.""" + + scope = forms.ChoiceField( + required=False, + choices=[("", "---------")] + NormalizationRule.SCOPE_CHOICES, + label="Scope", + ) + manufacturer_id = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + label="Manufacturer", + ) + + model = NormalizationRule + + class AddToLIbreSNMPV1V2(forms.Form): """ Form for adding devices to LibreNMS using SNMPv1 or SNMPv2c authentication. @@ -315,6 +520,7 @@ class AddToLIbreSNMPV1V2(forms.Form): ) def __init__(self, *args, **kwargs): + """Initialize form and populate poller group choices.""" super().__init__(*args, **kwargs) self.fields["poller_group"].choices = _get_librenms_poller_group_choices() @@ -412,6 +618,7 @@ class AddToLIbreSNMPV3(forms.Form): ) def __init__(self, *args, **kwargs): + """Initialize form and populate poller group choices.""" super().__init__(*args, **kwargs) self.fields["poller_group"].choices = _get_librenms_poller_group_choices() @@ -422,6 +629,7 @@ class DeviceStatusFilterForm(NetBoxModelFilterSetForm): """ def __init__(self, *args, **kwargs): + """Initialize form and remove saved filter field.""" super().__init__(*args, **kwargs) # Remove the saved filter field if it exists if "filter_id" in self.fields: @@ -581,7 +789,8 @@ def _populate_librenms_locations(self): try: # Use caching to avoid repeated API calls - cache_key = "librenms_locations_choices" + api = LibreNMSAPI() + cache_key = f"librenms_locations_choices:{api.server_key}" cached_choices = cache.get(cache_key) if cached_choices: @@ -589,7 +798,6 @@ def _populate_librenms_locations(self): return # Fetch locations from LibreNMS - api = LibreNMSAPI() success, locations = api.get_locations() if success and locations: diff --git a/netbox_librenms_plugin/import_utils.py b/netbox_librenms_plugin/import_utils.py deleted file mode 100644 index cb9c9a99e7..0000000000 --- a/netbox_librenms_plugin/import_utils.py +++ /dev/null @@ -1,2334 +0,0 @@ -""" -Utilities for importing devices from LibreNMS to NetBox. - -This module 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 -""" - -import logging -from typing import List - -from core.choices import JobStatusChoices -from dcim.models import Device, DeviceRole, DeviceType, Rack, Site, VirtualChassis -from django.core.cache import cache -from django.core.exceptions import PermissionDenied -from django.db import transaction -from django.utils import timezone -from virtualization.models import Cluster - -from .librenms_api import LibreNMSAPI -from .utils import ( - find_matching_platform, - find_matching_site, - match_librenms_hardware_to_device_type, -) - -logger = logging.getLogger(__name__) - - -# ============================================================================= -# Permission Check Helpers -# ============================================================================= - - -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}" - ) - - -def get_cache_metadata_key(server_key: str, filters: dict, vc_enabled: bool) -> 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 - - Returns: - str: Consistent cache key for metadata - """ - # Sort filter items to ensure consistent key generation - filter_parts = "_".join(f"{k}={v}" for k, v in sorted(filters.items()) if v) - return f"librenms_filter_cache_metadata_{server_key}_{filter_parts}_{vc_enabled}" - - -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 - location_cache_key = "librenms_locations_choices" - 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 - cached_at = datetime.fromisoformat(metadata.get("cached_at")) - cache_timeout = metadata.get("cache_timeout", 300) - now = datetime.now(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 - - # 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", ""), reverse=True) - - return active_searches - - -def get_validated_device_cache_key(server_key: str, filters: dict, device_id: int | str, vc_enabled: bool) -> 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 - - Returns: - str: Cache key for the validated device - - Example: - >>> key = get_validated_device_cache_key('default', {'location': 'NYC'}, 123, True) - >>> key - 'validated_device_default_-1234567890_123_vc' - """ - # Sort filters for consistent hashing - filter_hash = hash(str(sorted(filters.items()))) - vc_part = "vc" if vc_enabled else "novc" - return f"validated_device_{server_key}_{filter_hash}_{device_id}_{vc_part}" - - -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 - - 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 _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 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) - try: - member_copy["position"] = int(raw_position) - except (TypeError, ValueError): - member_copy["position"] = idx - 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) - if not force_refresh: - 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 and "detection_error" not in detection_data: - detection_data["detection_error"] = None - - cache_value = _clone_virtual_chassis_data(detection_data) if detection_data else empty_virtual_chassis_data() - - cache_timeout = getattr(api, "cache_timeout", 300) or 300 - 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 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 - if not show_disabled: - devices = [d for d in devices if d.get("status") == 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: - # Status filter uses special types that don't need query param - if filters["status"] == 1: - api_filters["type"] = "up" - elif filters["status"] == 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=) - # 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 - cache_key = f"librenms_devices_import_{server_key}_{hash(str(api_filters))}_{hash(str(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}") - 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", "").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", "").lower()] - - if filters.get("hostname"): - hostname_filter = filters["hostname"].lower() - filtered = [d for d in filtered if hostname_filter in d.get("hostname", "").lower()] - - if filters.get("sysname"): - sysname_filter = filters["sysname"].lower() - filtered = [d for d in filtered if sysname_filter in d.get("sysName", "").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 - - -def validate_device_for_import( - libre_device: dict, - import_as_vm: bool = False, - api: "LibreNMSAPI" = None, - *, - include_vc_detection: bool = True, - force_vc_refresh: 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 - - 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, - "existing_device": None, - "existing_match_type": None, # Track how existing device was matched - "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": [], - }, - } - - 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 = libre_device.get("hostname", "") - 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 - - # Check for existing VM first (by librenms_id custom field) - # Always query with int to match custom field type - try: - existing_vm = VirtualMachine.objects.filter(custom_field_data__librenms_id=int(librenms_id)).first() - except (ValueError, TypeError): - # librenms_id is not convertible to int; no match will be found - existing_vm = None - - 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["warnings"].append(f"VM already imported to NetBox as '{existing_vm.name}'") - result["can_import"] = False - return result - - # Check for existing Device (by librenms_id custom field) - # Always query with int to match custom field type - try: - existing_device = Device.objects.filter(custom_field_data__librenms_id=int(librenms_id)).first() - except (ValueError, TypeError): - # librenms_id is not convertible to int; no match will be found - existing_device = None - - 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["warnings"].append(f"Device already imported to NetBox as '{existing_device.name}'") - result["can_import"] = False - return result - - # 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 - return result - elif existing_device: - logger.info(f"Found existing device by hostname: {existing_device.name}") - result["existing_device"] = existing_device - result["existing_match_type"] = "hostname" - result["warnings"].append( - f"Device with same hostname exists in NetBox as '{existing_device.name}' (not linked to LibreNMS)" - ) - result["can_import"] = False - return result - - # Check by primary IP (weaker match, IP could be reassigned) - only for devices - 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__startswith=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 - return result - - # Validate based on import type (Device or VM) - if import_as_vm: - # 2. For VMs: Validate Cluster (required) - Must be manually selected - from virtualization.models import Cluster - - result["cluster"]["found"] = False - result["issues"].append("Cluster must be manually selected before importing as VM") - # Provide list of available clusters for user selection (cached) - 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 - - # Skip device-specific validations for VMs - 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"]: - 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) - result["device_type"] = dt_match - - if not dt_match["matched"]: - 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 - ] - else: - # Rename 'matched' to 'found' for consistency - result["device_type"]["found"] = dt_match["matched"] - result["device_type"]["device_type"] = dt_match["device_type"] - result["device_type"]["match_type"] = dt_match["match_type"] - - # 4. DeviceRole (required) - Must be manually selected by user - 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: - from dcim.models import Rack - from django.db.models import Q - - # 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") - - # Serial number check - serial = libre_device.get("serial", "") - if serial and serial != "-": - existing_serial = Device.objects.filter(serial=serial).first() - if existing_serial: - result["warnings"].append(f"Serial number {serial} already exists on device: {existing_serial.name}") - - # 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" - ) - 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 - 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: - validation = validate_device_for_import(libre_device) - - # 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 - - rack = rack or validation.get("rack", {}).get("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(): - # Determine device name based on sync options - 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") - - 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": {"librenms_id": int(device_id)}, - } - - # 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 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 - required_perms = [ - "dcim.add_device", - "dcim.add_interface", - "dcim.add_virtualchassis", - ] - 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 - - # 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 every 5 devices - if job and idx % 5 == 0: - # Refresh job from DB to get current status - job.job.refresh_from_db() - job_status = job.job.status - status_value = job_status.value if hasattr(job_status, "value") else job_status - if status_value in (JobStatusChoices.STATUS_FAILED, "failed", "errored"): - if job.logger: - job.logger.warning(f"Import job cancelled at device {idx} of {total}") - else: - logger.warning(f"Import cancelled at device {idx} of {total}") - break - # Log progress - if job.logger: - job.logger.info(f"Imported device {idx} of {total}") - - 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 - - validation = validate_device_for_import(libre_device, api=api) - - # 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=server_key, - 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"], - } - ) - - # Handle virtual chassis creation for stacks - vc_data = validation.get("virtual_chassis", {}) - if vc_data.get("is_stack", False): - vc_domain = f"librenms-{device_id}" - - # Only create VC if we haven't processed this stack yet - # Add to set BEFORE attempting creation to prevent race condition - if vc_domain not in processed_vc_domains: - processed_vc_domains.add(vc_domain) - try: - vc = create_virtual_chassis_with_members( - result["device"], - vc_data["members"], - libre_device, - ) - 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, - } - - -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 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 - - -def create_vm_from_librenms(libre_device: dict, validation: dict, use_sysname: bool = True, 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 - role: Optional DeviceRole to assign to the VM - - 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") - - # Determine VM name - use pre-computed name if available (handles strip_domain) - vm_name = libre_device.get("_computed_name") - if not vm_name: - vm_name = _determine_device_name( - libre_device, - use_sysname=use_sysname, - strip_domain=False, - device_id=libre_device.get("device_id"), - ) - - # Generate import timestamp comment - import_time = timezone.now().strftime("%Y-%m-%d %H:%M:%S %Z") - - # Create the VM with librenms_id custom field - vm = VirtualMachine.objects.create( - name=vm_name, - cluster=cluster, - role=role, # Optional VM role - platform=platform, - comments=f"Imported from LibreNMS by netbox-librenms-plugin on {import_time}", - custom_field_data={"librenms_id": int(libre_device["device_id"])}, - ) - - 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 every 5 VMs - if job and idx % 5 == 0: - job.job.refresh_from_db() - job_status = job.job.status - status_value = job_status.value if hasattr(job_status, "value") else job_status - if status_value in ("failed", "errored"): - log.warning(f"Job cancelled at VM {idx} of {len(vm_ids)}") - break - log.info(f"Imported 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 - validation = validate_device_for_import(libre_device, import_as_vm=True, api=api) - - # 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) - - role = None - if role_id: - role = DeviceRole.objects.filter(id=role_id).first() - if role: - apply_role_to_validation(validation, role, is_vm=True) - - # Determine VM name - 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 - - vm_name = _determine_device_name( - libre_device, - use_sysname=use_sysname, - strip_domain=strip_domain, - 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, role=role) - - 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 - - -def detect_virtual_chassis_from_inventory(api: LibreNMSAPI, device_id: int) -> dict: - """ - 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 - # Could be class="stack" or the main "chassis" - parent_index = None - for item in root_items: - item_class = item.get("entPhysicalClass") - if item_class in ["stack", "chassis"]: - parent_index = item.get("entPhysicalIndex") - logger.debug(f"VC detection: Found parent container at index {parent_index} for device {device_id}") - break - - if not parent_index: - 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 - members = [] - for idx, chassis in enumerate(chassis_items): - raw_position = chassis.get("entPhysicalParentRelPos", idx) - try: - position = int(raw_position) - except (TypeError, ValueError): - position = idx - member_data = { - "serial": chassis.get("entPhysicalSerialNum", ""), - "position": position, - "model": chassis.get("entPhysicalModelName", ""), - "name": chassis.get("entPhysicalName", ""), - "index": chassis.get("entPhysicalIndex"), - "description": chassis.get("entPhysicalDescr", ""), - } - - # Generate suggested name if we have master name - if master_name: - member_data["suggested_name"] = _generate_vc_member_name(master_name, position + 1) - else: - member_data["suggested_name"] = f"Member-{position + 1}" - - members.append(member_data) - - # Sort by position - members.sort(key=lambda m: m["position"]) - - logger.info(f"Detected stack with {len(members)} members for device {device_id}") - - 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 _generate_vc_member_name(master_name: str, position: int, serial: 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 - - Returns: - Generated member device name - - Examples: - pattern="-M{position}" β†’ "switch01-M2" - pattern=" ({position})" β†’ "switch01 (2)" - pattern="-SW{position}" β†’ "switch01-SW2" - pattern=" [{serial}]" β†’ "switch01 [ABC123]" - """ - # Import here to avoid circular dependency - from .models import LibreNMSSettings - - # Get pattern from settings with fallback to default - try: - settings = LibreNMSSettings.objects.first() - pattern = settings.vc_member_name_pattern if settings else "-M{position}" - except Exception as e: - logger.warning(f"Could not load VC member name pattern from settings: {e}. Using default.") - pattern = "-M{position}" - - # 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 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 - - for idx, member in enumerate(vc_data.get("members", [])): - raw_position = member.get("position", idx) - try: - base_position = int(raw_position) - except (TypeError, ValueError): - base_position = idx - position = base_position + 1 # Convert to 1-based position - member["position"] = base_position - member["suggested_name"] = _generate_vc_member_name(master_name, position, serial=member.get("serial")) - - return vc_data - - -def create_virtual_chassis_with_members(master_device: Device, members_info: list, libre_device: dict): - """ - 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'} - ] - """ - - # Store original master device state for rollback - original_master_name = master_device.name - original_vc = master_device.virtual_chassis - original_vc_position = master_device.vc_position - - try: - with transaction.atomic(): - # Rename master device to include position 1 pattern - master_device_new_name = _generate_vc_member_name(original_master_name, 1, serial=master_device.serial) - - # 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 - else: - master_device.name = master_device_new_name - master_base_name = original_master_name - - # Create VC using original base name - vc_name = master_base_name - vc = VirtualChassis.objects.create( - name=vc_name, - master=master_device, - domain=f"librenms-{libre_device['device_id']}", - ) - - # Update master device - master_device.virtual_chassis = vc - master_device.vc_position = 1 # Master is position 1 - master_device.save() - - # Create member devices for remaining positions - position = 2 # Start at 2 (master is 1) - members_created = 0 - - for member in members_info: - # Skip if this is the master's serial - if member.get("serial") == master_device.serial: - continue - - serial = member.get("serial") - - 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 - - member_name = _generate_vc_member_name(master_base_name, position, serial=serial) - - # 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=position, - comments=f"VC member (LibreNMS: {member.get('name', 'Unknown')})\n" - f"Auto-created from stack inventory", - ) - members_created += 1 - position += 1 - - # Validate member count - expected_members = len([m for m in members_info if m.get("serial") != master_device.serial]) - 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." - ) - - 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: - # Rollback master device to original state - logger.error( - f"Virtual Chassis creation failed for device {master_device.name}: {e}. Rolling back master device changes." - ) - master_device.name = original_master_name - master_device.virtual_chassis = original_vc - master_device.vc_position = original_vc_position - master_device.save() - raise - - -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, -) -> 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 - - 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}") - 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 - if not show_disabled: - libre_devices = [d for d in libre_devices if d.get("status") == 1] - - if job: - job.logger.info(f"Found {len(libre_devices)} devices to process") - else: - logger.info(f"Found {len(libre_devices)} devices") - - # 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 [] - raise - - # Validate each device - validated_devices = [] - total = len(libre_devices) - api_for_validation = api if vc_detection_enabled else None - - if job: - job.logger.info(f"Starting validation of {total} devices") - # Initial check if job was already terminated before we even started - try: - from django_rq import get_queue - from rq.job import Job as RQJob - - queue = get_queue("default") - rq_job = RQJob.fetch(str(job.job.job_id), connection=queue.connection) - - if rq_job.is_failed or rq_job.is_stopped: - job.logger.warning("Job was already stopped before validation started") - return [] - except Exception: - # Fall back to DB check if RQ check fails - job.job.refresh_from_db() - if job.job.status == JobStatusChoices.STATUS_FAILED: - job.logger.warning("Job was stopped before validation started") - return [] - else: - logger.info(f"Validating {total} devices") - - for idx, device in enumerate(libre_devices, 1): - # Check for job termination or client disconnect periodically - if idx % 5 == 0 or idx == 1: # Check more frequently (every 5 devices + first device) - if job: - # Check if job was terminated via stop API - # CRITICAL: Check the RQ job status in Redis, not just the DB model - # NetBox's stop endpoint marks the RQ job as failed in Redis - try: - from django_rq import get_queue - from rq.job import Job as RQJob - - queue = get_queue("default") - rq_job = RQJob.fetch(str(job.job.job_id), connection=queue.connection) - - # Check if RQ job is in a stopped state - if rq_job.is_failed or rq_job.is_stopped: - job.logger.info( - f"Job stopped at device {idx}/{total} (RQ status: {rq_job.get_status()}). Exiting gracefully." - ) - return [] - except Exception: - # If we can't check RQ status, fall back to DB status check - job.job.refresh_from_db() - if job.job.status == JobStatusChoices.STATUS_FAILED: - job.logger.info(f"Job stopped at device {idx}/{total}. Exiting gracefully.") - return [] - elif request: - # Check for client disconnect - try: - if hasattr(request, "META") and request.META.get("wsgi.input"): - pass - except (BrokenPipeError, ConnectionError, IOError): - logger.info(f"Client disconnected during validation at device {idx}") - return [] - - # 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, - ) - - # 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"] - - # 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_for_validation, - include_vc_detection=vc_detection_enabled, - force_vc_refresh=clear_cache, - ) - except (BrokenPipeError, ConnectionError, IOError) as e: - if request: - logger.info(f"Client disconnected during device validation: {e}") - return [] - 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 - ) - - # 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) - # Store index with same timeout as the metadata - 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 diff --git a/netbox_librenms_plugin/import_utils/__init__.py b/netbox_librenms_plugin/import_utils/__init__.py new file mode 100644 index 0000000000..b7a08fef59 --- /dev/null +++ b/netbox_librenms_plugin/import_utils/__init__.py @@ -0,0 +1,52 @@ +""" +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_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 diff --git a/netbox_librenms_plugin/import_utils/bulk_import.py b/netbox_librenms_plugin/import_utils/bulk_import.py new file mode 100644 index 0000000000..5072070c56 --- /dev/null +++ b/netbox_librenms_plugin/import_utils/bulk_import.py @@ -0,0 +1,661 @@ +"""Bulk import orchestration for devices and filter processing.""" + +import logging +from typing import List + +from core.choices import JobStatusChoices +from django.core.cache import cache + +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 get_librenms_devices_for_import +from .permissions import 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 _empty_return(return_cache_status: bool): + """Centralised empty-result return value for process_device_filters.""" + return ([], False) if return_cache_status else [] + + +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 + required_perms = [ + "dcim.add_device", + "dcim.add_interface", + "dcim.add_virtualchassis", + ] + 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 + + # 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. + # Check RQ/Redis state first (reflects stop API immediately); fall back to DB. + if job and (idx == 1 or idx % 5 == 0): + try: + from django_rq import get_queue + from rq.job import Job as RQJob + + queue = get_queue("default") + rq_job = RQJob.fetch(str(job.job.job_id), connection=queue.connection) + if rq_job.is_failed or rq_job.is_stopped: + if job.logger: + job.logger.warning( + f"Import job stopped at device {idx} of {total} (RQ status: {rq_job.get_status()})" + ) + else: + logger.warning(f"Import cancelled at device {idx} of {total}") + break + except Exception: + # Fall back to DB check if RQ is unavailable + job.job.refresh_from_db() + job_status = job.job.status + status_value = job_status.value if hasattr(job_status, "value") else job_status + if status_value in (JobStatusChoices.STATUS_FAILED, "failed", "errored"): + if job.logger: + job.logger.warning(f"Import job cancelled at device {idx} of {total}") + else: + logger.warning(f"Import cancelled at device {idx} of {total}") + 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, + ) + + # 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) + 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 + vc_data = validation.get("virtual_chassis", {}) + 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(m.get("serial") for m in vc_data.get("members", []) if m.get("serial")) + vc_domain = ( + f"librenms-stack-{','.join(member_serials)}" if member_serials else f"librenms-{device_id}" + ) + + # Only create VC if we haven't processed this stack yet + # Add to set BEFORE attempting creation to prevent race condition + if vc_domain not in processed_vc_domains: + processed_vc_domains.add(vc_domain) + try: + vc = create_virtual_chassis_with_members( + result["device"], + vc_data["members"], + libre_device, + ) + 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, + } + + +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: + validation["device_role"] = {"found": True, "role": refreshed.role} + else: + # Device was deleted since caching β€” recompute readiness to match + # validate_device_for_import logic. + validation["existing_device"] = None + validation["existing_match_type"] = None + can_import = not bool(validation.get("issues")) + if validation.get("import_as_vm"): + # VMs only require a cluster (site/role not mandatory) + is_ready = can_import and bool(validation.get("cluster", {}).get("found")) + else: + is_ready = ( + can_import + and bool(validation.get("site", {}).get("found")) + and bool(validation.get("device_type", {}).get("found")) + and bool(validation.get("device_role", {}).get("found")) + ) + validation["can_import"] = can_import + validation["is_ready"] = is_ready + 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 + + librenms_id = libre_device.get("device_id") + hostname = libre_device.get("hostname", "") + sys_name = libre_device.get("sysName", "") + + new_device = None + match_type = None + + # Check by librenms_id custom field first (JSON multi-server format + legacy) + if librenms_id: + try: + new_device = find_by_librenms_id(Model, int(librenms_id), server_key) + if new_device: + match_type = "librenms_id" + except (ValueError, TypeError): + pass + + # Fall back to hostname match, then sys_name independently + if not new_device and hostname: + new_device = Model.objects.filter(name__iexact=hostname).first() + if new_device: + match_type = "hostname" + if not new_device and sys_name: + new_device = Model.objects.filter(name__iexact=sys_name).first() + if new_device: + match_type = "hostname" + + if new_device: + validation["existing_device"] = new_device + validation["existing_match_type"] = match_type + validation["can_import"] = False + validation["is_ready"] = False + if not import_as_vm and hasattr(new_device, "role") and new_device.role: + validation["device_role"] = {"found": True, "role": new_device.role} + except Exception as e: + logger.error(f"Failed to check for newly imported device: {e}") + + +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}") + 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 int(d.get("disabled", 0)) != 1] + + if job: + job.logger.info(f"Found {len(libre_devices)} devices to process") + else: + logger.info(f"Found {len(libre_devices)} devices") + + # 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) + api_for_validation = api if vc_detection_enabled else None + + if job: + job.logger.info(f"Starting validation of {total} devices") + # Initial check if job was already terminated before we even started + try: + from django_rq import get_queue + from rq.job import Job as RQJob + + queue = get_queue("default") + rq_job = RQJob.fetch(str(job.job.job_id), connection=queue.connection) + + if rq_job.is_failed or rq_job.is_stopped: + job.logger.warning("Job was already stopped before validation started") + return _empty_return(return_cache_status) + except Exception: + # Fall back to DB check if RQ check fails + job.job.refresh_from_db() + if job.job.status in (JobStatusChoices.STATUS_FAILED, JobStatusChoices.STATUS_ERRORED): + job.logger.warning("Job was 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 or client disconnect periodically + if idx % 5 == 0 or idx == 1: # Check more frequently (every 5 devices + first device) + if job: + # Check if job was terminated via stop API + # CRITICAL: Check the RQ job status in Redis, not just the DB model + # NetBox's stop endpoint marks the RQ job as failed in Redis + try: + from django_rq import get_queue + from rq.job import Job as RQJob + + queue = get_queue("default") + rq_job = RQJob.fetch(str(job.job.job_id), connection=queue.connection) + + # Check if RQ job is in a stopped state + if rq_job.is_failed or rq_job.is_stopped: + job.logger.info( + f"Job stopped at device {idx}/{total} (RQ status: {rq_job.get_status()}). Exiting gracefully." + ) + return _empty_return(return_cache_status) + except Exception: + # If we can't check RQ status, fall back to DB status check + job.job.refresh_from_db() + if job.job.status in (JobStatusChoices.STATUS_FAILED, JobStatusChoices.STATUS_ERRORED): + 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_for_validation, + include_vc_detection=vc_detection_enabled, + force_vc_refresh=clear_cache, + 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) + # Store index with same timeout as the metadata + 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 diff --git a/netbox_librenms_plugin/import_utils/cache.py b/netbox_librenms_plugin/import_utils/cache.py new file mode 100644 index 0000000000..140f45f79d --- /dev/null +++ b/netbox_librenms_plugin/import_utils/cache.py @@ -0,0 +1,186 @@ +"""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 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). + filter_parts = "_".join(f"{k}={v}" for k, v in sorted(filters.items()) if v is not None) + return f"librenms_filter_cache_metadata_{server_key}_{filter_parts}_{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 = f"librenms_locations_choices:{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") + cached_at = ( + datetime.fromisoformat(cached_at_raw) if cached_at_raw else 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 + + # 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", ""), 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 + filter_hash = hashlib.sha256(json.dumps(sorted(filters.items()), sort_keys=True).encode()).hexdigest()[:16] + 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 + + 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}" diff --git a/netbox_librenms_plugin/import_utils/device_operations.py b/netbox_librenms_plugin/import_utils/device_operations.py new file mode 100644 index 0000000000..487c915cf1 --- /dev/null +++ b/netbox_librenms_plugin/import_utils/device_operations.py @@ -0,0 +1,966 @@ +"""Device validation, import, and fetch operations.""" + +import logging + +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_matching_platform, + find_matching_site, + match_librenms_hardware_to_device_type, +) +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["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, + *, + include_vc_detection: bool = True, + force_vc_refresh: bool = False, + use_sysname: bool = True, + strip_domain: bool = False, + server_key: str = "default", +) -> 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 librenms_id is still a legacy bare int + "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 + result["naming_criteria"] = { + "use_sysname": use_sysname, + "strip_domain": strip_domain, + "raw_sysname": libre_device.get("sysName") or "", + "raw_hostname": libre_device.get("hostname") or "", + "source": "sysname" if use_sysname and libre_device.get("sysName") else "hostname", + } + 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 + + # Check for existing VM first (by librenms_id custom field) + try: + from netbox_librenms_plugin.utils import find_by_librenms_id + + existing_vm = find_by_librenms_id(VirtualMachine, int(librenms_id), server_key) + except (ValueError, TypeError): + # librenms_id is not convertible to int; no match will be found + existing_vm = None + + 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 format so UI can offer a migration action. + # Direct access needed to detect legacy integer 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. + if isinstance(existing_vm.custom_field_data.get("librenms_id"), int): + result["librenms_id_needs_migration"] = True + + # Check if name matches resolved name (accounts for use_sysname/strip_domain) + # Note: name_sync_available/suggested_name are intentionally not set for VMs + # because UpdateDeviceNameView only supports Device objects; VM name-sync + # would require a separate implementation. + if hostname and existing_vm.name == hostname: + result["name_matches"] = True + + # Check for existing Device (by librenms_id custom field) + if not result["existing_device"]: + try: + from netbox_librenms_plugin.utils import find_by_librenms_id + + existing_device = find_by_librenms_id(Device, int(librenms_id), server_key) + except (ValueError, TypeError): + # librenms_id is not convertible to int; no match will be found + existing_device = None + + 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 format so UI can offer a migration action. + # Direct access needed to detect legacy integer 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. + if isinstance(existing_device.custom_field_data.get("librenms_id"), int): + 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: + vc_expected_name = _generate_vc_member_name( + hostname, + existing_device.vc_position, + serial=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: + # 2. For VMs: Validate Cluster (required) - Must be manually selected + result["cluster"]["found"] = False + result["issues"].append("Cluster must be manually selected before importing as VM") + # Provide list of available clusters for user selection (cached) + 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 + + # Skip device-specific validations for VMs + 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"]: + 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) + + # 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 dt_match["matched"]: + result["device_type"]["found"] = False + 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 + ] + + # 4. DeviceRole (required) - Must be manually selected by user + 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, + 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 + + rack = rack or validation.get("rack", {}).get("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(): + # Determine device name based on sync options + 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") + + 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": {"librenms_id": {api.server_key: int(device_id)}}, + } + + # 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 diff --git a/netbox_librenms_plugin/import_utils/filters.py b/netbox_librenms_plugin/import_utils/filters.py new file mode 100644 index 0000000000..3e12658066 --- /dev/null +++ b/netbox_librenms_plugin/import_utils/filters.py @@ -0,0 +1,268 @@ +"""Device filtering and retrieval from LibreNMS.""" + +import hashlib +import json +import logging +from typing import List + +from django.core.cache import cache + +from ..librenms_api import LibreNMSAPI + +logger = logging.getLogger(__name__) + + +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 int(d.get("disabled", 0)) != 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=) + # 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). + def _hash(d): + return hashlib.sha256( + json.dumps(sorted(d.items()) if isinstance(d, dict) else d, sort_keys=True).encode() + ).hexdigest()[:16] + + cache_key = f"librenms_devices_import_{api.server_key}_{_hash(api_filters)}_{_hash(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}") + 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", "").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", "").lower()] + + if filters.get("hostname"): + hostname_filter = filters["hostname"].lower() + filtered = [d for d in filtered if hostname_filter in d.get("hostname", "").lower()] + + if filters.get("sysname"): + sysname_filter = filters["sysname"].lower() + filtered = [d for d in filtered if sysname_filter in d.get("sysName", "").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 diff --git a/netbox_librenms_plugin/import_utils/permissions.py b/netbox_librenms_plugin/import_utils/permissions.py new file mode 100644 index 0000000000..742e50893c --- /dev/null +++ b/netbox_librenms_plugin/import_utils/permissions.py @@ -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}" + ) diff --git a/netbox_librenms_plugin/import_utils/virtual_chassis.py b/netbox_librenms_plugin/import_utils/virtual_chassis.py new file mode 100644 index 0000000000..b95f32eaec --- /dev/null +++ b/netbox_librenms_plugin/import_utils/virtual_chassis.py @@ -0,0 +1,470 @@ +"""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: + member_copy["position"] = int(raw_position) + 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) + if not force_refresh: + 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 and "detection_error" not in detection_data: + detection_data["detection_error"] = None + + cache_value = _clone_virtual_chassis_data(detection_data) if detection_data else empty_virtual_chassis_data() + + cache_timeout = getattr(api, "cache_timeout", 300) or 300 + 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: + """ + 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 + # Could be class="stack" or the main "chassis" + parent_index = None + for item in root_items: + item_class = item.get("entPhysicalClass") + if item_class in ["stack", "chassis"]: + parent_index = item.get("entPhysicalIndex") + logger.debug(f"VC detection: Found parent container at index {parent_index} for device {device_id}") + break + + 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 + members = [] + for idx, chassis in enumerate(chassis_items): + # entPhysicalParentRelPos is 1-based; fall back to idx+1 (not idx) so + # position 0 is never produced β€” VC positions must be β‰₯ 1. + raw_position = chassis.get("entPhysicalParentRelPos", idx + 1) + try: + position = int(raw_position) + except (TypeError, ValueError): + position = idx + 1 + member_data = { + "serial": chassis.get("entPhysicalSerialNum", ""), + "position": position, + "model": chassis.get("entPhysicalModelName", ""), + "name": chassis.get("entPhysicalName", ""), + "index": chassis.get("entPhysicalIndex"), + "description": chassis.get("entPhysicalDescr", ""), + } + + # 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) + else: + member_data["suggested_name"] = f"Member-{position}" + + members.append(member_data) + + # Sort by position + members.sort(key=lambda m: m["position"]) + + logger.info(f"Detected stack with {len(members)} members for device {device_id}") + + 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 + + try: + settings = LibreNMSSettings.objects.first() + return settings.vc_member_name_pattern if settings else "-M{position}" + except Exception as e: + logger.warning(f"Could not load VC member name pattern from settings: {e}. Using default.") + return "-M{position}" + + +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 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", [])): + raw_position = member.get("position", idx) + try: + base_position = int(raw_position) + except (TypeError, ValueError): + base_position = idx + position = base_position + 1 # Convert to 1-based position + member["position"] = base_position + member["suggested_name"] = _generate_vc_member_name( + master_name, position, serial=member.get("serial"), pattern=vc_pattern + ) + + return vc_data + + +def create_virtual_chassis_with_members(master_device: Device, members_info: list, libre_device: dict): + """ + 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'} + ] + """ + + # original_master_name is still referenced in warning messages inside the atomic block. + original_master_name = master_device.name + + try: + with transaction.atomic(): + # Rename master device to include position 1 pattern + master_device_new_name = _generate_vc_member_name(original_master_name, 1, serial=master_device.serial) + + # 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 + else: + master_device.name = master_device_new_name + master_base_name = original_master_name + + # Create VC using original base name + vc_name = master_base_name + vc = VirtualChassis.objects.create( + name=vc_name, + master=master_device, + domain=f"librenms-{libre_device.get('device_id', master_device.pk)}", + ) + + # Update master device + master_device.virtual_chassis = vc + master_device.vc_position = 1 # Master is position 1 + master_device.save() + + # Create member devices for remaining positions + position = 2 # Start at 2 (master is 1) + members_created = 0 + # Load naming pattern once to avoid a DB query per member + vc_pattern = _load_vc_member_name_pattern() + + for member in members_info: + # Skip if this is the master's serial (only when both serials are non-empty) + if member.get("serial") and member.get("serial") == master_device.serial: + continue + + serial = member.get("serial") + + 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. + # Normalize discovered_pos: 0 is not a valid VC position, treat as absent. + try: + discovered_pos = int(member.get("position")) if member.get("position") is not None else None + except (TypeError, ValueError): + discovered_pos = None + if discovered_pos is not None and discovered_pos < 1: + discovered_pos = None # 0 is invalid for vc_position; fall back to counter + chosen_pos = discovered_pos if discovered_pos is not None else position + # Advance the sequential counter: + # - if discovered_pos was used, advance counter past it to avoid future reuse; + # - if counter was consumed as fallback, increment it normally. + if discovered_pos is None: + position += 1 + else: + position = max(position, discovered_pos + 1) + + 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 + expected_members = len( + [m for m in members_info if not (m.get("serial") and m.get("serial") == master_device.serial)] + ) + 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." + ) + + 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: + # The transaction.atomic() block above will roll back all DB changes automatically. + # Manual state restoration is redundant and the save() would fail in a broken transaction. + logger.error( + f"Virtual Chassis creation failed for device {master_device.name}: {e}", + exc_info=True, + ) + raise diff --git a/netbox_librenms_plugin/import_utils/vm_operations.py b/netbox_librenms_plugin/import_utils/vm_operations.py new file mode 100644 index 0000000000..1272871a5a --- /dev/null +++ b/netbox_librenms_plugin/import_utils/vm_operations.py @@ -0,0 +1,229 @@ +"""Virtual machine creation and import operations.""" + +import logging + +from dcim.models import DeviceRole +from django.utils import timezone +from virtualization.models import Cluster + +from ..librenms_api import LibreNMSAPI +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, use_sysname: bool = True, role=None, server_key: str = "default" +): + """ + 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 + role: Optional DeviceRole to assign to the VM + + 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") + + # Determine VM name - use pre-computed name if available (handles strip_domain) + vm_name = libre_device.get("_computed_name") + if not vm_name: + vm_name = _determine_device_name( + libre_device, + use_sysname=use_sysname, + strip_domain=False, + device_id=libre_device.get("device_id"), + ) + + # Generate import timestamp comment + import_time = timezone.now().strftime("%Y-%m-%d %H:%M:%S %Z") + + # Create the VM with librenms_id custom field + vm = VirtualMachine.objects.create( + name=vm_name, + cluster=cluster, + role=role, # Optional VM role + platform=platform, + comments=f"Imported from LibreNMS by netbox-librenms-plugin on {import_time}", + custom_field_data={"librenms_id": {server_key: int(libre_device["device_id"])}}, + ) + + 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 every 5 VMs + if job and idx % 5 == 0: + job.job.refresh_from_db() + job_status = job.job.status + status_value = job_status.value if hasattr(job_status, "value") else job_status + if status_value in ("failed", "errored"): + log.warning(f"Job cancelled at VM {idx} of {len(vm_ids)}") + break + log.info(f"Imported 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) + + role = None + if role_id: + role = DeviceRole.objects.filter(id=role_id).first() + if role: + apply_role_to_validation(validation, role, is_vm=True) + + # Determine VM name + 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 + + vm_name = _determine_device_name( + libre_device, + use_sysname=use_sysname, + strip_domain=strip_domain, + 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, role=role, 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 diff --git a/netbox_librenms_plugin/jobs.py b/netbox_librenms_plugin/jobs.py index bb3d00e37d..f476c1f7e7 100644 --- a/netbox_librenms_plugin/jobs.py +++ b/netbox_librenms_plugin/jobs.py @@ -46,6 +46,8 @@ def run( show_disabled, exclude_existing=False, server_key=None, + use_sysname=True, + strip_domain=False, **kwargs, ): """ @@ -60,6 +62,8 @@ def run( show_disabled: Whether to include disabled devices exclude_existing: Whether to exclude devices that already exist in NetBox server_key: Optional LibreNMS server key for multi-server setups + use_sysname: If True, prefer sysName over hostname + strip_domain: If True, strip domain suffix from device name **kwargs: Additional job parameters """ from netbox_librenms_plugin.import_utils import process_device_filters @@ -88,6 +92,8 @@ def run( show_disabled=show_disabled, exclude_existing=exclude_existing, job=self, + use_sysname=use_sysname, + strip_domain=strip_domain, ) # Store device IDs for result retrieval @@ -107,6 +113,8 @@ def run( "filters": filters, "server_key": server_key, "vc_detection_enabled": vc_detection_enabled, + "use_sysname": use_sysname, + "strip_domain": strip_domain, "cache_timeout": api.cache_timeout, "cached_at": cached_at, "completed": True, diff --git a/netbox_librenms_plugin/librenms_api.py b/netbox_librenms_plugin/librenms_api.py index 5de9db6c25..3291aad0cc 100644 --- a/netbox_librenms_plugin/librenms_api.py +++ b/netbox_librenms_plugin/librenms_api.py @@ -190,7 +190,9 @@ def get_librenms_id(self, obj): If found via API, stores ID in custom field if available, otherwise caches the value. """ - librenms_id = obj.cf.get("librenms_id") + from netbox_librenms_plugin.utils import get_librenms_device_id + + librenms_id = get_librenms_device_id(obj, self.server_key) if librenms_id: return librenms_id @@ -254,7 +256,9 @@ def _store_librenms_id(self, obj, librenms_id): None """ if "librenms_id" in obj.cf: - obj.custom_field_data["librenms_id"] = librenms_id + from netbox_librenms_plugin.utils import set_librenms_device_id + + set_librenms_device_id(obj, librenms_id, self.server_key) obj.save() else: # Use cache as fallback @@ -686,6 +690,51 @@ def get_device_inventory(self, device_id): except requests.exceptions.RequestException as e: return False, str(e) + def get_device_transceivers(self, device_id): + """ + Fetch all transceiver data for a device from LibreNMS. + + Route: /api/v0/devices/{device_id}/transceivers + + This is a separate data source from entity inventory. Some vendors + (e.g., Nokia/SROS) don't expose SFPs via ENTITY-MIB but do report + them through vendor-specific MIBs which LibreNMS surfaces here. + + Args: + device_id: LibreNMS device ID + + Returns: + tuple: (success: bool, data: list) + + Example transceiver item: + { + "port_id": 519, + "entity_physical_index": 1610899520, + "type": "CFP2/QSFP28", + "model": "3HE10550AARA01", + "serial": "X42AU0D", + "channels": 4, + "connector": "LC", + "wavelength": 1301, + ... + } + """ + try: + response = requests.get( + f"{self.librenms_url}/api/v0/devices/{device_id}/transceivers", + headers=self.headers, + timeout=DEFAULT_API_TIMEOUT, + verify=self.verify_ssl, + ) + response.raise_for_status() + + if response.status_code == 200: + data = response.json() + return True, data.get("transceivers", []) + return False, [] + except requests.exceptions.RequestException as e: + return False, str(e) + def get_poller_groups(self): """ Fetch all poller groups from LibreNMS. diff --git a/netbox_librenms_plugin/migrations/0009_add_devicetypemapping.py b/netbox_librenms_plugin/migrations/0009_add_devicetypemapping.py new file mode 100644 index 0000000000..dcd8fc4fd5 --- /dev/null +++ b/netbox_librenms_plugin/migrations/0009_add_devicetypemapping.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2.10 on 2026-02-17 11:48 + +import django.db.models.deletion +import netbox.models.deletion +import taggit.managers +import utilities.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("dcim", "0225_gfk_indexes"), + ("extras", "0134_owner"), + ("netbox_librenms_plugin", "0008_librenmssettings_import_defaults"), + ] + + operations = [ + migrations.CreateModel( + name="DeviceTypeMapping", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ("created", models.DateTimeField(auto_now_add=True, null=True)), + ("last_updated", models.DateTimeField(auto_now=True, null=True)), + ( + "custom_field_data", + models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ("librenms_hardware", models.CharField(max_length=255, unique=True)), + ("description", models.TextField(blank=True)), + ( + "netbox_device_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="librenms_mappings", + to="dcim.devicetype", + ), + ), + ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")), + ], + options={ + "ordering": ["librenms_hardware"], + }, + bases=(netbox.models.deletion.DeleteMixin, models.Model), + ), + ] diff --git a/netbox_librenms_plugin/migrations/0010_add_moduletypemapping.py b/netbox_librenms_plugin/migrations/0010_add_moduletypemapping.py new file mode 100644 index 0000000000..796bbceafd --- /dev/null +++ b/netbox_librenms_plugin/migrations/0010_add_moduletypemapping.py @@ -0,0 +1,49 @@ +# Generated by Django 5.2.10 on 2026-02-17 12:23 + +import django.db.models.deletion +import netbox.models.deletion +import taggit.managers +import utilities.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("dcim", "0225_gfk_indexes"), + ("extras", "0134_owner"), + ("netbox_librenms_plugin", "0009_add_devicetypemapping"), + ] + + operations = [ + migrations.AlterModelOptions( + name="interfacetypemapping", + options={"ordering": ["librenms_type", "librenms_speed"]}, + ), + migrations.CreateModel( + name="ModuleTypeMapping", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ("created", models.DateTimeField(auto_now_add=True, null=True)), + ("last_updated", models.DateTimeField(auto_now=True, null=True)), + ( + "custom_field_data", + models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ("librenms_model", models.CharField(max_length=255, unique=True)), + ("description", models.TextField(blank=True)), + ( + "netbox_module_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="librenms_mappings", + to="dcim.moduletype", + ), + ), + ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")), + ], + options={ + "ordering": ["librenms_model"], + }, + bases=(netbox.models.deletion.DeleteMixin, models.Model), + ), + ] diff --git a/netbox_librenms_plugin/migrations/0011_modulebaymapping.py b/netbox_librenms_plugin/migrations/0011_modulebaymapping.py new file mode 100644 index 0000000000..5b3c2c3be0 --- /dev/null +++ b/netbox_librenms_plugin/migrations/0011_modulebaymapping.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.10 on 2026-02-17 12:29 + +import netbox.models.deletion +import taggit.managers +import utilities.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("extras", "0134_owner"), + ("netbox_librenms_plugin", "0010_add_moduletypemapping"), + ] + + operations = [ + migrations.CreateModel( + name="ModuleBayMapping", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ("created", models.DateTimeField(auto_now_add=True, null=True)), + ("last_updated", models.DateTimeField(auto_now=True, null=True)), + ( + "custom_field_data", + models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ("librenms_name", models.CharField(max_length=255)), + ("librenms_class", models.CharField(blank=True, max_length=50)), + ("netbox_bay_name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True)), + ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")), + ], + options={ + "ordering": ["librenms_name"], + "unique_together": {("librenms_name", "librenms_class")}, + }, + bases=(netbox.models.deletion.DeleteMixin, models.Model), + ), + ] diff --git a/netbox_librenms_plugin/migrations/0012_add_is_regex_to_modulebaymapping.py b/netbox_librenms_plugin/migrations/0012_add_is_regex_to_modulebaymapping.py new file mode 100644 index 0000000000..52ff053e20 --- /dev/null +++ b/netbox_librenms_plugin/migrations/0012_add_is_regex_to_modulebaymapping.py @@ -0,0 +1,18 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("netbox_librenms_plugin", "0011_modulebaymapping"), + ] + + operations = [ + migrations.AddField( + model_name="modulebaymapping", + name="is_regex", + field=models.BooleanField( + default=False, + help_text="Treat LibreNMS Name as a regex pattern with backreferences in NetBox Bay Name", + ), + ), + ] diff --git a/netbox_librenms_plugin/migrations/0013_normalizationrule.py b/netbox_librenms_plugin/migrations/0013_normalizationrule.py new file mode 100644 index 0000000000..71d1f80509 --- /dev/null +++ b/netbox_librenms_plugin/migrations/0013_normalizationrule.py @@ -0,0 +1,93 @@ +"""Restore NormalizationRule model. + +The table was created by earlier migrations (0013 + 0014 in a previous branch) +and already exists in the database. This migration uses SeparateDatabaseAndState +so Django's ORM knows about the model without trying to CREATE the table again. +If the table doesn't exist (fresh install), the database_operations handle creation. +""" + +import django.db.models.deletion +import taggit.managers +import utilities.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("dcim", "0001_initial"), + ("extras", "0001_initial"), + ("netbox_librenms_plugin", "0012_add_is_regex_to_modulebaymapping"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.CreateModel( + name="NormalizationRule", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ("created", models.DateTimeField(auto_now_add=True, null=True)), + ("last_updated", models.DateTimeField(auto_now=True, null=True)), + ( + "custom_field_data", + models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ( + "scope", + models.CharField( + choices=[ + ("module_type", "Module Type"), + ("device_type", "Device Type"), + ("module_bay", "Module Bay"), + ], + max_length=50, + ), + ), + ("match_pattern", models.CharField(max_length=500)), + ("replacement", models.CharField(max_length=500)), + ("priority", models.PositiveIntegerField(default=100)), + ("description", models.TextField(blank=True)), + ( + "manufacturer", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="normalization_rules", + to="dcim.manufacturer", + ), + ), + ( + "tags", + taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag"), + ), + ], + options={ + "ordering": ["scope", "priority", "pk"], + }, + ), + ], + database_operations=[ + migrations.RunSQL( + sql=""" + CREATE TABLE IF NOT EXISTS "netbox_librenms_plugin_normalizationrule" ( + "id" bigserial NOT NULL PRIMARY KEY, + "created" timestamp with time zone NULL, + "last_updated" timestamp with time zone NULL, + "custom_field_data" jsonb NOT NULL DEFAULT '{}'::jsonb, + "scope" varchar(50) NOT NULL, + "match_pattern" varchar(500) NOT NULL, + "replacement" varchar(500) NOT NULL, + "priority" integer NOT NULL DEFAULT 100 CHECK ("priority" >= 0), + "description" text NOT NULL DEFAULT '', + "manufacturer_id" bigint NULL REFERENCES "dcim_manufacturer" ("id") + DEFERRABLE INITIALLY DEFERRED + ); + CREATE INDEX IF NOT EXISTS "netbox_librenms_plugin_norm_mfg_idx" + ON "netbox_librenms_plugin_normalizationrule" ("manufacturer_id"); + """, + reverse_sql="DROP TABLE IF EXISTS netbox_librenms_plugin_normalizationrule;", + ), + ], + ), + ] diff --git a/netbox_librenms_plugin/models.py b/netbox_librenms_plugin/models.py index cd79f47550..3978d9c5f4 100644 --- a/netbox_librenms_plugin/models.py +++ b/netbox_librenms_plugin/models.py @@ -1,4 +1,8 @@ +import re + from dcim.choices import InterfaceTypeChoices +from dcim.models import DeviceType, Manufacturer, ModuleType +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from netbox.models import NetBoxModel @@ -71,6 +75,205 @@ class Meta: """Meta options for InterfaceTypeMapping.""" unique_together = ["librenms_type", "librenms_speed"] + ordering = ["librenms_type", "librenms_speed"] def __str__(self): return f"{self.librenms_type} + {self.librenms_speed} -> {self.netbox_type}" + + +class DeviceTypeMapping(NetBoxModel): + """Map LibreNMS hardware strings to NetBox DeviceType objects.""" + + librenms_hardware = models.CharField( + max_length=255, + unique=True, + help_text="Hardware string as reported by LibreNMS (e.g., 'Juniper MX480 Internet Backbone Router')", + ) + netbox_device_type = models.ForeignKey( + DeviceType, + on_delete=models.CASCADE, + related_name="librenms_mappings", + help_text="The NetBox DeviceType this hardware string maps to", + ) + description = models.TextField( + blank=True, + help_text="Optional description or notes about this mapping", + ) + + def get_absolute_url(self): + """Return the URL for this mapping's detail page.""" + return reverse("plugins:netbox_librenms_plugin:devicetypemapping_detail", args=[self.pk]) + + class Meta: + """Meta options for DeviceTypeMapping.""" + + ordering = ["librenms_hardware"] + + def __str__(self): + return f"{self.librenms_hardware} -> {self.netbox_device_type}" + + +class ModuleTypeMapping(NetBoxModel): + """Map LibreNMS inventory model names to NetBox ModuleType objects.""" + + librenms_model = models.CharField( + max_length=255, + unique=True, + help_text="Model name from LibreNMS inventory (entPhysicalModelName)", + ) + netbox_module_type = models.ForeignKey( + ModuleType, + on_delete=models.CASCADE, + related_name="librenms_mappings", + help_text="The NetBox ModuleType this model name maps to", + ) + description = models.TextField( + blank=True, + help_text="Optional description or notes about this mapping", + ) + + def get_absolute_url(self): + """Return the URL for this mapping's detail page.""" + return reverse("plugins:netbox_librenms_plugin:moduletypemapping_detail", args=[self.pk]) + + class Meta: + """Meta options for ModuleTypeMapping.""" + + ordering = ["librenms_model"] + + def __str__(self): + return f"{self.librenms_model} -> {self.netbox_module_type}" + + +class ModuleBayMapping(NetBoxModel): + """Map LibreNMS inventory names to NetBox module bay names. + + Used when LibreNMS inventory names don't match NetBox bay names exactly. + For example: LibreNMS "Power Supply 1" β†’ NetBox "PS1". + When is_regex is True, librenms_name is treated as a regex pattern and + netbox_bay_name can use backreferences (\\1, \\2, etc.). + Mappings are global (not scoped to device type or manufacturer). + """ + + librenms_name = models.CharField( + max_length=255, + help_text="Name from LibreNMS inventory (entPhysicalName). " + "When 'Use Regex' is enabled, this is a Python regex pattern.", + ) + librenms_class = models.CharField( + max_length=50, + blank=True, + help_text="Optional entPhysicalClass filter (e.g. 'powerSupply', 'fan', 'module')", + ) + netbox_bay_name = models.CharField( + max_length=255, + help_text="NetBox module bay name to match. With regex, supports backreferences (\\1, \\2, etc.).", + ) + is_regex = models.BooleanField( + default=False, + help_text="Treat LibreNMS Name as a regex pattern with backreferences in NetBox Bay Name", + ) + description = models.TextField( + blank=True, + help_text="Optional description or notes about this mapping", + ) + + def clean(self): + """Validate that regex patterns compile when is_regex is True.""" + super().clean() + if self.is_regex: + try: + re.compile(self.librenms_name) + except re.error as e: + raise ValidationError({"librenms_name": f"Invalid regex: {e}"}) + + def get_absolute_url(self): + """Return the URL for this mapping's detail page.""" + return reverse("plugins:netbox_librenms_plugin:modulebaymapping_detail", args=[self.pk]) + + class Meta: + """Meta options for ModuleBayMapping.""" + + unique_together = ["librenms_name", "librenms_class"] + ordering = ["librenms_name"] + + def __str__(self): + cls = f" [{self.librenms_class}]" if self.librenms_class else "" + return f"{self.librenms_name}{cls} -> {self.netbox_bay_name}" + + +class NormalizationRule(NetBoxModel): + """Regex-based string normalization applied before matching lookups. + + Generic building block: a single rule engine handles normalization + for module types, device types, module bays, and future scopes. + Rules are applied in priority order; each transforms the string + for the next rule in the chain. + + Example – strip Nokia revision suffixes: + scope: module_type + match_pattern: ^(3HE\\w{5}[A-Z]{2})[A-Z]{2}\\d{2}$ + replacement: \\1 + Result: 3HE16474AARA01 β†’ 3HE16474AA + """ + + SCOPE_MODULE_TYPE = "module_type" + SCOPE_DEVICE_TYPE = "device_type" + SCOPE_MODULE_BAY = "module_bay" + + SCOPE_CHOICES = [ + (SCOPE_MODULE_TYPE, "Module Type"), + (SCOPE_DEVICE_TYPE, "Device Type"), + (SCOPE_MODULE_BAY, "Module Bay"), + ] + + scope = models.CharField( + max_length=50, + choices=SCOPE_CHOICES, + help_text="Which matching lookup this rule applies to", + ) + manufacturer = models.ForeignKey( + Manufacturer, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="normalization_rules", + help_text="Optional: only apply this rule to items from this manufacturer. " + "Leave blank for vendor-agnostic rules.", + ) + match_pattern = models.CharField( + max_length=500, + help_text="Regex pattern to match against input string (Python re syntax)", + ) + replacement = models.CharField( + max_length=500, + help_text="Replacement string (supports regex back-references \\1, \\2, …)", + ) + priority = models.PositiveIntegerField( + default=100, + help_text="Lower values run first. Rules chain: each transforms the output of the previous.", + ) + description = models.TextField( + blank=True, + help_text="Optional description or notes about this rule", + ) + + def clean(self): + """Validate that match_pattern compiles as a regex.""" + super().clean() + try: + re.compile(self.match_pattern) + except re.error as e: + raise ValidationError({"match_pattern": f"Invalid regex: {e}"}) + + def get_absolute_url(self): + """Return the URL for this rule's detail page.""" + return reverse("plugins:netbox_librenms_plugin:normalizationrule_detail", args=[self.pk]) + + class Meta: + """Meta options for NormalizationRule.""" + + ordering = ["scope", "priority", "pk"] + + def __str__(self): + return f"[{self.get_scope_display()}] {self.match_pattern} β†’ {self.replacement}" diff --git a/netbox_librenms_plugin/navigation.py b/netbox_librenms_plugin/navigation.py index a08e62740f..052c06363c 100644 --- a/netbox_librenms_plugin/navigation.py +++ b/netbox_librenms_plugin/navigation.py @@ -31,6 +31,74 @@ ), ), ), + PluginMenuItem( + link="plugins:netbox_librenms_plugin:devicetypemapping_list", + link_text="Device Type Mappings", + permissions=[PERM_VIEW_PLUGIN], + buttons=( + PluginMenuButton( + link="plugins:netbox_librenms_plugin:devicetypemapping_add", + title="Add", + icon_class="mdi mdi-plus-thick", + ), + PluginMenuButton( + link="plugins:netbox_librenms_plugin:devicetypemapping_bulk_import", + title="Import", + icon_class="mdi mdi-upload", + ), + ), + ), + PluginMenuItem( + link="plugins:netbox_librenms_plugin:moduletypemapping_list", + link_text="Module Type Mappings", + permissions=[PERM_VIEW_PLUGIN], + buttons=( + PluginMenuButton( + link="plugins:netbox_librenms_plugin:moduletypemapping_add", + title="Add", + icon_class="mdi mdi-plus-thick", + ), + PluginMenuButton( + link="plugins:netbox_librenms_plugin:moduletypemapping_bulk_import", + title="Import", + icon_class="mdi mdi-upload", + ), + ), + ), + PluginMenuItem( + link="plugins:netbox_librenms_plugin:modulebaymapping_list", + link_text="Module Bay Mappings", + permissions=[PERM_VIEW_PLUGIN], + buttons=( + PluginMenuButton( + link="plugins:netbox_librenms_plugin:modulebaymapping_add", + title="Add", + icon_class="mdi mdi-plus-thick", + ), + PluginMenuButton( + link="plugins:netbox_librenms_plugin:modulebaymapping_bulk_import", + title="Import", + icon_class="mdi mdi-upload", + ), + ), + ), + PluginMenuItem( + link="plugins:netbox_librenms_plugin:normalizationrule_list", + link_text="Normalization Rules", + permissions=[PERM_VIEW_PLUGIN], + buttons=( + PluginMenuButton( + link="plugins:netbox_librenms_plugin:normalizationrule_add", + title="Add", + icon_class="mdi mdi-plus-thick", + ), + PluginMenuButton( + link="plugins:netbox_librenms_plugin:normalizationrule_bulk_import", + title="Import", + icon_class="mdi mdi-upload", + ), + ), + ), ), ), ( diff --git a/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js b/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js index c89473c02a..2e130b1284 100644 --- a/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js +++ b/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js @@ -310,6 +310,8 @@ * * @param {HTMLElement} modalElement - The modal element to hide * @param {Object} fallbackBackdropRef - Reference object containing fallback backdrop (deprecated) + * WONTFIX: fallbackBackdropRef is unused β€” _hideManual uses querySelector which is + * correct for this plugin since only one modal is ever open at a time (Tabler, no Bootstrap). */ function hideModal(modalElement, fallbackBackdropRef) { if (!modalElement) { @@ -317,6 +319,14 @@ } const manager = new ModalManager(modalElement); + + // Try to recover an existing Bootstrap instance before falling back to manual + if (typeof bootstrap !== 'undefined' && bootstrap.Modal) { + manager.instance = bootstrap.Modal.getInstance(modalElement); + } else if (typeof window.bootstrap !== 'undefined' && window.bootstrap.Modal) { + manager.instance = window.bootstrap.Modal.getInstance(modalElement); + } + manager.hide(); } @@ -338,6 +348,7 @@ function pollJobStatus(jobId, jobPk, pollUrl, baseUrl, originalFilters, deviceCount) { const messageEl = document.getElementById('filter-progress-message'); const cancelBtn = document.getElementById('cancel-filter-btn'); + const filterModal = document.getElementById('filter-processing-modal'); // Get CSRF token from cookie or form (needed for cancel and status sync) let csrfToken = getCookie('csrftoken'); @@ -402,11 +413,8 @@ messageEl.textContent = 'Job already completed, loading results...'; } cancelBtn.textContent = 'Completed'; - - const modal = document.getElementById('filter-processing-modal'); - if (modal) { - const manager = new ModalManager(modal); - manager.hide(); + if (filterModal) { + hideModal(filterModal); } setTimeout(() => { @@ -448,11 +456,8 @@ messageEl.textContent = 'Job cancelled successfully.'; } cancelBtn.textContent = 'Cancelled'; - - const modal = document.getElementById('filter-processing-modal'); - if (modal) { - const manager = new ModalManager(modal); - manager.hide(); + if (filterModal) { + hideModal(filterModal); } setTimeout(() => { @@ -469,11 +474,8 @@ messageEl.textContent = 'Job stopped (status sync failed).'; } cancelBtn.textContent = 'Stopped'; - - const modal = document.getElementById('filter-processing-modal'); - if (modal) { - const manager = new ModalManager(modal); - manager.hide(); + if (filterModal) { + hideModal(filterModal); } setTimeout(() => { @@ -486,11 +488,8 @@ messageEl.textContent = 'Job completed, loading results...'; } cancelBtn.textContent = 'Completed'; - - const modal = document.getElementById('filter-processing-modal'); - if (modal) { - const manager = new ModalManager(modal); - manager.hide(); + if (filterModal) { + hideModal(filterModal); } setTimeout(() => { @@ -505,11 +504,8 @@ } cancelBtn.textContent = 'Close'; cancelBtn.disabled = false; - - const modal = document.getElementById('filter-processing-modal'); - if (modal) { - const manager = new ModalManager(modal); - manager.hide(); + if (filterModal) { + hideModal(filterModal); } setTimeout(() => window.location.href = baseUrl, 1000); @@ -590,11 +586,8 @@ if (statusValue === 'completed' || statusValue === 'finished') { pollingStopped = true; // Stop future polls - - const modal = document.getElementById('filter-processing-modal'); - if (modal) { - const manager = new ModalManager(modal); - manager.hide(); + if (filterModal) { + hideModal(filterModal); } // Small delay to let modal close before redirect @@ -604,21 +597,15 @@ return; // Stop polling } else if (statusValue === 'stopped') { pollingStopped = true; - - const modal = document.getElementById('filter-processing-modal'); - if (modal) { - const manager = new ModalManager(modal); - manager.hide(); + if (filterModal) { + hideModal(filterModal); } setTimeout(() => window.location.href = baseUrl, 100); } else if (statusValue === 'failed') { pollingStopped = true; - - const modal = document.getElementById('filter-processing-modal'); - if (modal) { - const manager = new ModalManager(modal); - manager.hide(); + if (filterModal) { + hideModal(filterModal); } const errorMsg = data.data?.error; @@ -628,11 +615,8 @@ setTimeout(() => window.location.href = baseUrl, 100); } else if (statusValue === 'errored') { pollingStopped = true; - - const modal = document.getElementById('filter-processing-modal'); - if (modal) { - const manager = new ModalManager(modal); - manager.hide(); + if (filterModal) { + hideModal(filterModal); } const errorMsg = data.data?.error || 'Job encountered an error. Please try again.'; @@ -1031,11 +1015,8 @@ if (failedCount && failedCount.dataset.failedCount === '0') { setTimeout(() => { const resultsModal = document.getElementById('import-results-modal'); - if (resultsModal && typeof bootstrap !== 'undefined' && bootstrap.Modal) { - const modalInstance = bootstrap.Modal.getInstance(resultsModal); - if (modalInstance) { - modalInstance.hide(); - } + if (resultsModal) { + hideModal(resultsModal); } window.location.reload(); }, MODAL_AUTO_CLOSE_MS); @@ -1113,6 +1094,12 @@ } showModal(modalElement, fallbackBackdropRef); + + // Re-initialize tooltips for newly swapped modal content + if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip) { + const tooltipEls = modalContent.querySelectorAll('[data-bs-toggle="tooltip"]'); + [...tooltipEls].map(el => new bootstrap.Tooltip(el)); + } } document.body.addEventListener('htmx:afterSwap', ensureModalVisible); diff --git a/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js b/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js index cd470af1b1..0b42112b24 100644 --- a/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js +++ b/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js @@ -153,11 +153,15 @@ function initializeCountdowns() { if (window.vlanCountdownInterval) { clearInterval(window.vlanCountdownInterval); } + if (window.moduleCountdownInterval) { + clearInterval(window.moduleCountdownInterval); + } window.interfaceCountdownInterval = initializeCountdown("countdown-timer"); window.cableCountdownInterval = initializeCountdown("cable-countdown-timer"); window.ipCountdownInterval = initializeCountdown("ip-countdown-timer"); window.vlanCountdownInterval = initializeCountdown("vlan-countdown-timer"); + window.moduleCountdownInterval = initializeCountdown("module-countdown-timer"); } // ============================================ diff --git a/netbox_librenms_plugin/tables/device_status.py b/netbox_librenms_plugin/tables/device_status.py index 8bf978ccd4..3f03764cef 100644 --- a/netbox_librenms_plugin/tables/device_status.py +++ b/netbox_librenms_plugin/tables/device_status.py @@ -444,7 +444,7 @@ def render_actions(self, value, record): buttons = [] if existing: - # Link to existing device/VM in NetBox + # Link to existing device/VM in NetBox + details button for conflict resolution if isinstance(existing, VirtualMachine): url_name = "virtualization:virtualmachine" title = "View VM in NetBox" @@ -457,6 +457,50 @@ def render_actions(self, value, record): f'' ) + + # Add details/conflict button for conflict resolution actions + details_url = self._build_validation_details_url(device_id, validation) + match_type = validation.get("existing_match_type", "") + serial_action = validation.get("serial_action") + has_mismatch = validation.get("device_type_mismatch", False) + has_actions = match_type == "hostname" or (match_type == "serial" and serial_action is not None) + has_name_sync = validation.get("name_sync_available", False) + has_sync_needed = match_type == "librenms_id" and serial_action in ("update_serial", "conflict") + + if has_mismatch: + btn_class = "btn-outline-danger" + btn_icon = "mdi-alert-circle" + btn_label = " Conflict" + elif has_actions: + btn_class = "btn-outline-warning" + btn_icon = "mdi-alert" + btn_label = " Conflict" + elif has_name_sync or has_sync_needed: + btn_class = "btn-outline-warning" + btn_icon = "mdi-information-outline" + btn_label = " Details" + elif match_type == "librenms_id" and validation.get("librenms_id_needs_migration"): + btn_class = "btn-outline-warning" + btn_icon = "mdi-database-alert" + btn_label = " Legacy ID" + else: + btn_class = "btn-outline-success" + btn_icon = "mdi-check-circle" + btn_label = "" + + btn_title = "Resolve conflict" if (has_actions or has_mismatch) else "View details" + aria_attr = f'aria-label="{btn_title}" ' if btn_label == "" else "" + buttons.append( + f'' + ) elif is_ready: # Ready to import - show Import and Details buttons details_url = self._build_validation_details_url(device_id, validation) @@ -473,7 +517,7 @@ def render_actions(self, value, record): f'", + url, + self.csrf_token, + record.get("module_bay_id", ""), + record.get("module_type_id", ""), + record.get("serial", ""), + ) + ) + + # Install branch button for parents with installable children + if record.get("has_installable_children") and record.get("ent_physical_index"): + url = reverse("plugins:netbox_librenms_plugin:install_branch", kwargs={"pk": self.device.pk}) + buttons.append( + format_html( + '
' + '' + '' + '
", + url, + self.csrf_token, + record.get("ent_physical_index", ""), + ) + ) + + return format_html("{}", format_html("".join(str(b) for b in buttons))) if buttons else "" diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync_content.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync_content.html new file mode 100644 index 0000000000..ce6e430bfa --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync_content.html @@ -0,0 +1,31 @@ +{% load helpers %} +{% include 'inc/messages.html' %} + + +{% if module_sync.table %} +
+
+ + Showing inventory items from LibreNMS matched against NetBox module bays and module types. + +
+ {% if module_sync.cache_expiry %} +
+ Cache expires in: +
+ {% endif %} +
+ +
+ {% include 'netbox_librenms_plugin/inc/paginator.html' with table=module_sync.table %} + {% include 'inc/table.html' with table=module_sync.table %} + {% include 'netbox_librenms_plugin/inc/paginator.html' with table=module_sync.table %} +
+{% else %} +
+
+ +

No inventory data loaded. Click Refresh Modules to fetch data from LibreNMS.

+
+
+{% endif %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping.html new file mode 100644 index 0000000000..3894441179 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping.html @@ -0,0 +1,28 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+ + + + + + + + + + + + + + + +
LibreNMS HardwareNetBox Device TypeDescription
{{ object.librenms_hardware }}{{ object.netbox_device_type }}{{ object.description|default:"β€”" }}
+
+
+
+{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping_list.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping_list.html new file mode 100644 index 0000000000..06c95270b3 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping_list.html @@ -0,0 +1,12 @@ +{% extends 'generic/object_list.html' %} + +{% block content %} +
+

Device Type Mapping

+

Map LibreNMS hardware strings to NetBox device types. + When importing devices from LibreNMS, these mappings are checked first before + falling back to exact part number / model matching.

+

Example: Map "Juniper MX480 Internet Backbone Router" to device type "MX480"

+
+ {{ block.super }} +{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html index 31ea3df6cb..9b16c47436 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html @@ -1,325 +1,586 @@ {# HTMX template for device validation details modal #} -{# Shows detailed reasons why a device cannot be imported #} +{# Redesigned to match the sync page's clean table layout #}