From 18030d042dba72373b5a69613ae5eacbcb8279ea Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Sun, 3 May 2026 13:43:00 +0200 Subject: [PATCH 01/98] feat(oob-sync): OOB management controller support, detection, and migration Add LibreNMS out-of-band (OOB) management controller support: detect and link OOB controllers, store the controller as an id+type sub-object under the librenms_id custom field, surface OOB rows in the interfaces/cables/modules tables, and support post-merge device migration. --- netbox_librenms_plugin/constants.py | 24 + .../import_utils/__init__.py | 2 + .../import_utils/collisions.py | 166 ++++ .../import_utils/device_operations.py | 310 ++++++- .../import_validation_helpers.py | 71 ++ netbox_librenms_plugin/librenms_api.py | 13 +- .../js/librenms_import.js | 48 +- netbox_librenms_plugin/tables/cables.py | 9 +- .../tables/device_status.py | 62 +- netbox_librenms_plugin/tables/interfaces.py | 10 +- netbox_librenms_plugin/tables/modules.py | 10 +- .../_interface_sync_content.html | 18 + .../htmx/_dt_mapping_form.html | 2 +- .../htmx/bulk_import_collision.html | 61 ++ .../htmx/device_import_row.html | 5 +- .../htmx/device_validation_details.html | 478 ++++++++++- .../librenms_sync_base.html | 57 ++ .../tests/test_collisions.py | 202 +++++ .../tests/test_coverage_actions.py | 406 ++++++++- .../tests/test_coverage_base_views.py | 101 +++ .../tests/test_coverage_device_operations.py | 801 ++++++++++++++++++ .../tests/test_coverage_list.py | 6 +- .../tests/test_import_utils.py | 23 +- .../tests/test_import_validation_helpers.py | 168 ++++ .../tests/test_librenms_id.py | 429 ++++++++++ .../tests/test_migrate_views.py | 394 +++++++++ netbox_librenms_plugin/urls.py | 36 + netbox_librenms_plugin/utils.py | 384 ++++++++- netbox_librenms_plugin/views/__init__.py | 14 +- .../views/base/cables_view.py | 42 + .../views/base/interfaces_view.py | 35 +- .../views/base/librenms_sync_view.py | 31 + .../views/base/modules_view.py | 70 +- .../views/imports/actions.py | 727 +++++++++++++++- netbox_librenms_plugin/views/imports/list.py | 9 +- netbox_librenms_plugin/views/sync/migrate.py | 383 +++++++++ 36 files changed, 5506 insertions(+), 101 deletions(-) create mode 100644 netbox_librenms_plugin/import_utils/collisions.py create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/bulk_import_collision.html create mode 100644 netbox_librenms_plugin/tests/test_collisions.py create mode 100644 netbox_librenms_plugin/tests/test_migrate_views.py create mode 100644 netbox_librenms_plugin/views/sync/migrate.py diff --git a/netbox_librenms_plugin/constants.py b/netbox_librenms_plugin/constants.py index 4e542f9d1..b4892c03c 100644 --- a/netbox_librenms_plugin/constants.py +++ b/netbox_librenms_plugin/constants.py @@ -1,6 +1,30 @@ +import re + # Plugin permissions (from LibreNMSSettings model) PERM_VIEW_PLUGIN = "netbox_librenms_plugin.view_librenmssettings" PERM_CHANGE_PLUGIN = "netbox_librenms_plugin.change_librenmssettings" # LibreNMS VLAN state values LIBRENMS_VLAN_STATE_ACTIVE = 1 + +# OOB management controller detection +OOB_TYPE_PATTERN = re.compile(r"\b(idrac|ilo|ipmi|bmc|drac|oob)", re.IGNORECASE) +OOB_TYPES = ("idrac", "ilo", "ipmi", "bmc", "drac", "oob") + + +def normalize_oob_type(os_str: str, hardware_str: str = "") -> str | None: + """ + Extract and normalize the OOB controller type from LibreNMS os/hardware strings. + + Returns the canonical lowercase token (one of OOB_TYPES) or None if no match. + + Examples: + normalize_oob_type("drac9", "iDRAC9") → "drac" + normalize_oob_type("ilo", "") → "ilo" + normalize_oob_type("ubuntu", "") → None + """ + for text in (os_str or "", hardware_str or ""): + m = OOB_TYPE_PATTERN.search(text) + if m: + return m.group(1).lower() + return None diff --git a/netbox_librenms_plugin/import_utils/__init__.py b/netbox_librenms_plugin/import_utils/__init__.py index 81c24025e..385c7ff40 100644 --- a/netbox_librenms_plugin/import_utils/__init__.py +++ b/netbox_librenms_plugin/import_utils/__init__.py @@ -33,6 +33,8 @@ import_single_device, validate_device_for_import, ) +from .ip_helpers import auto_create_ipam_enabled, get_or_create_global_ip # noqa: F401 +from .collisions import detect_bulk_collisions # noqa: F401 from .filters import ( # noqa: F401 _apply_client_filters, get_device_count_for_filters, diff --git a/netbox_librenms_plugin/import_utils/collisions.py b/netbox_librenms_plugin/import_utils/collisions.py new file mode 100644 index 000000000..41d19d014 --- /dev/null +++ b/netbox_librenms_plugin/import_utils/collisions.py @@ -0,0 +1,166 @@ +""" +Bulk-import collision detection. + +When a user selects multiple LibreNMS devices for bulk import, two or more +rows in the same batch may resolve to the *same* NetBox device — for +example, one row would be linked as the host and another as the OOB +controller, or two rows both want to promote to the same existing host. +Importing all of them blindly would race for the same custom-field slot +and produce inconsistent state. + +`detect_bulk_collisions` walks the per-row validation results and groups +rows that target the same NetBox device pk so the bulk-confirm view can +block the import and let the user adjust their selection. +""" + +from __future__ import annotations + + +def _candidate_pks_for_row(validation: dict) -> list[tuple[int, str, str, str]]: + """Return [(nb_device_pk, nb_device_name, role, model_name)] candidates for a single row. + + ``role`` is a short human-readable label describing how this LibreNMS + row would touch the NetBox device: + + * ``"host"`` — the device is the existing NetBox device this row would + update / link to (``existing_device``). + * ``"oob"`` — this LibreNMS row would be installed as the OOB + controller of the NetBox device (``oob_candidate.device``). + * ``"merge_host_named"`` / ``"merge_oob_named"`` — the row would feed + a Stage-2 merge of two NetBox devices. + * ``"promote_target"`` — the row should be promoted to host of an + existing NetBox device that currently only has an OOB link + (``promote_to_host``). + + ``model_name`` is the Python class name of the NetBox object + (e.g. ``"Device"``, ``"VirtualMachine"``) so that two objects of + different types that happen to share the same pk are not grouped as + collisions. + + Duplicate ``(pk, role, model_name)`` tuples are de-duplicated; a + single row may legitimately surface the same pk under different roles. + """ + candidates: list[tuple[int, str, str, str]] = [] + seen: set[tuple[int, str, str]] = set() + + def _add(pk, name, role, model_name="Device"): + try: + pk_int = int(pk) + except (TypeError, ValueError): + return + key = (pk_int, role, model_name) + if key in seen: + return + seen.add(key) + candidates.append((pk_int, str(name or f"device-{pk_int}"), role, model_name)) + + existing = validation.get("existing_device") + if existing is not None and getattr(existing, "pk", None) is not None: + _add(existing.pk, getattr(existing, "name", None), "host", type(existing).__name__) + + oob_candidate = validation.get("oob_candidate") or {} + oob_device = oob_candidate.get("device") if isinstance(oob_candidate, dict) else None + if oob_device is not None and getattr(oob_device, "pk", None) is not None: + _add(oob_device.pk, getattr(oob_device, "name", None), "oob", type(oob_device).__name__) + + merge = validation.get("merge_candidates") or {} + if isinstance(merge, dict): + for slot, role in (("host_named", "merge_host_named"), ("oob_named", "merge_oob_named")): + entry = merge.get(slot) or {} + pk = entry.get("pk") if isinstance(entry, dict) else None + name = entry.get("name") if isinstance(entry, dict) else None + if pk is not None: + _add(pk, name, role) + + promote = validation.get("promote_to_host") or {} + if isinstance(promote, dict): + target = promote.get("existing_device") + if target is not None and getattr(target, "pk", None) is not None: + _add(target.pk, getattr(target, "name", None), "promote_target", type(target).__name__) + + return candidates + + +def detect_bulk_collisions(devices: list[dict] | None) -> list[dict]: + """Find groups of LibreNMS rows in *devices* that resolve to the same NetBox device. + + *devices* matches the list assembled by ``BulkImportConfirmView`` — + each item is a dict with at least ``device_id``, ``device_name`` and + ``validation`` keys. ``None`` is accepted and treated as an empty list. + + Returns a list of collision groups (one per offending NetBox device), + sorted by model type then ``nb_device_pk`` for stable rendering (the + composite key prevents false collisions when a Device and a + VirtualMachine share the same integer pk). Each group: + + .. code-block:: python + + { + "nb_device_pk": int, + "nb_device_name": str, + "librenms_rows": [ + {"device_id": int, "hostname": str, "role": str}, + ... + ], + } + + Rows are de-duplicated by ``device_id`` within a group (same LibreNMS + row touching the same NetBox device under multiple roles only appears + once, with all matching role labels joined by ``", "``). + + A group is only emitted when at least two distinct LibreNMS + ``device_id`` values target the same NetBox pk. + """ + # Key by (model_name, nb_pk) to avoid false collisions when a Device + # and a VirtualMachine happen to share the same integer pk. + by_nb_pk: dict[tuple[str, int], dict] = {} + + for entry in devices or []: + validation = entry.get("validation") or {} + try: + libre_id = int(entry.get("device_id")) + except (TypeError, ValueError): + continue + hostname = entry.get("device_name") or f"device-{libre_id}" + + for nb_pk, nb_name, role, model_name in _candidate_pks_for_row(validation): + bucket_key = (model_name, nb_pk) + bucket = by_nb_pk.setdefault( + bucket_key, + {"nb_device_pk": nb_pk, "nb_device_name": nb_name, "_rows": {}}, + ) + # Keep the first non-default name we see — rows often disagree + # on the cached display string, but the underlying pk is the + # source of truth. + if bucket["nb_device_name"].startswith("device-") and not nb_name.startswith("device-"): + bucket["nb_device_name"] = nb_name + + row = bucket["_rows"].setdefault( + libre_id, + {"device_id": libre_id, "hostname": hostname, "roles": []}, + ) + if role not in row["roles"]: + row["roles"].append(role) + + collisions: list[dict] = [] + for _model_name, nb_pk in sorted(by_nb_pk.keys()): + bucket = by_nb_pk[(_model_name, nb_pk)] + rows = list(bucket["_rows"].values()) + if len(rows) < 2: + continue + rows.sort(key=lambda r: r["device_id"]) + collisions.append( + { + "nb_device_pk": bucket["nb_device_pk"], + "nb_device_name": bucket["nb_device_name"], + "librenms_rows": [ + { + "device_id": r["device_id"], + "hostname": r["hostname"], + "role": ", ".join(r["roles"]), + } + for r in rows + ], + } + ) + return collisions diff --git a/netbox_librenms_plugin/import_utils/device_operations.py b/netbox_librenms_plugin/import_utils/device_operations.py index 6b87935b0..296435259 100644 --- a/netbox_librenms_plugin/import_utils/device_operations.py +++ b/netbox_librenms_plugin/import_utils/device_operations.py @@ -12,12 +12,16 @@ from ..librenms_api import LibreNMSAPI from ..utils import ( + coerce_librenms_id, find_by_librenms_id, find_matching_platform, find_matching_site, + get_librenms_oob, match_librenms_hardware_to_device_type, set_librenms_device_id, ) +from ..constants import OOB_TYPE_PATTERN, normalize_oob_type +from ..import_validation_helpers import apply_merge_candidates, apply_oob_detection_result from .cache import get_import_device_cache_key from .virtual_chassis import ( _generate_vc_member_name, @@ -29,6 +33,52 @@ logger = logging.getLogger(__name__) +def _detect_oob_type_from_name(name): + """Return canonical OOB type token (idrac/ilo/ipmi/bmc/drac) found in *name*, or None.""" + if not name: + return None + m = OOB_TYPE_PATTERN.search(name) + return m.group(1).lower() if m else None + + +def _describe_existing_librenms_link(obj, server_key): + """ + Describe the current LibreNMS linkage on a NetBox object. + + Returns a dict ``{"host_id": int|None, "oob_id": int|None, "oob_type": str|None}`` + summarising the ``librenms_id`` custom field for *server_key*. Always returns a + dict (with all-None values if nothing is linked) so callers can treat it as a + plain status object. Tolerates legacy bare-int and dict-form custom field values. + """ + info = {"host_id": None, "oob_id": None, "oob_type": None} + cf_value = obj.cf.get("librenms_id") if hasattr(obj, "cf") else None + # Legacy bare-int OR string-digit (pre-JSON format). + if not isinstance(cf_value, dict): + info["host_id"] = coerce_librenms_id(cf_value) + return info + entry = cf_value.get(server_key) + # Per-server simple form: legacy bare-int or string-digit under the server key. + if not isinstance(entry, dict): + info["host_id"] = coerce_librenms_id(entry) + return info + # New dict-form: {"id": , "oob": {"id": , "type": , ...}}. + # Use coerce_librenms_id so string-digit values (e.g. "42") stored by older + # plugin versions are still recognized, matching the behaviour of + # find_by_librenms_id() and get_librenms_device_id(). + host_id = coerce_librenms_id(entry.get("id")) + if host_id is not None and host_id > 0: + info["host_id"] = host_id + oob = entry.get("oob") + if isinstance(oob, dict): + oob_id = coerce_librenms_id(oob.get("id")) + if oob_id is not None and oob_id > 0: + info["oob_id"] = oob_id + oob_type = oob.get("type") + if isinstance(oob_type, str) and oob_type: + info["oob_type"] = oob_type + return info + + def _try_chassis_device_type_match(api, device_id): """ Attempt device type matching using chassis inventory fields. @@ -206,10 +256,15 @@ def validate_device_for_import( "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_action": None, # None, "link", "conflict", "update_serial", "hostname_differs", "oob_candidate", "promote_to_host", "merge_netbox_devices" "serial_confirmed": False, # True when librenms_id match and serial matches "serial_duplicate": False, # True when incoming serial is already on a different device + "serial_role_choice_available": False, # True when both oob_candidate and promote_to_host are valid choices "librenms_id_needs_migration": False, # True when existing device has legacy bare-int ID + "oob_candidate": None, # dict {device, type, version, ip} when oob_candidate detected + # promote_to_host is only set when the host-promotion path is available; absent otherwise. + "existing_librenms_link": None, # dict {host_id, oob_id, oob_type} describing existing device's current LibreNMS linkage + "merge_candidates": None, # dict {host_named: {pk,name,librenms_link}, oob_named: {pk,name,librenms_link}} when two NB devices look like the same physical box "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 @@ -325,6 +380,15 @@ def validate_device_for_import( result["existing_match_type"] = "librenms_id" result["can_import"] = False + # If the match was via the OOB sub-key, mark it so the UI shows no duplicate warning. + _existing_oob = get_librenms_oob(existing_device, server_key=server_key) + if _existing_oob and coerce_librenms_id(_existing_oob.get("id")) == coerce_librenms_id(librenms_id): + result["existing_match_type"] = "librenms_oob" + + # Surface the full host/OOB linkage so the import table can render + # both halves of an existing pair with consistent paired styling. + result["existing_librenms_link"] = _describe_existing_librenms_link(existing_device, server_key) + # Detect legacy bare-integer or string-digit format so UI can offer a migration action. # Direct access needed to detect legacy format for migration prompt: # LibreNMSAPI.get_librenms_id() returns an int in both formats, so only the @@ -450,18 +514,199 @@ def validate_device_for_import( 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)" + # Capture existing device's current LibreNMS linkage so the UI can + # present accurate state (NOT just "not linked to LibreNMS"). + existing_link = _describe_existing_librenms_link(existing_by_serial, server_key) + result["existing_librenms_link"] = existing_link + + # Compute both possible roles for the incoming LibreNMS device against + # the existing NetBox device, then pick a heuristic default. The UI + # offers a manual toggle whenever both roles are feasible so the user + # can override the heuristic (e.g. mark a "linux"-OS device as OOB or + # demote an apparent host into the OOB slot). + oob_type_from_libre = normalize_oob_type( + libre_device.get("os", ""), + libre_device.get("hardware", ""), + ) + existing_oob = get_librenms_oob(existing_by_serial, server_key=server_key) + + # Only treat this as a possible host/OOB chassis-pair situation when + # there is a real ambiguity: either the existing NetBox device's name + # differs from the incoming LibreNMS hostname (so they likely represent + # two sides of one physical box), or the existing device is already + # linked to a different LibreNMS id. When names match exactly and the + # existing has no link, the user almost certainly just wants to link. + names_match = bool( + existing_by_serial.name and existing_by_serial.name.lower() == hostname.lower() + ) + # Normalize to int so that a string device_id from the API + # (e.g. "17") doesn't cause a false "linked elsewhere" result + # when compared to the int host_id from coerce_librenms_id. + normalized_device_id = coerce_librenms_id(libre_device.get("device_id")) + already_linked_elsewhere = bool( + existing_link + and existing_link["host_id"] + and existing_link["host_id"] != normalized_device_id + ) + chassis_pair_likely = (not names_match) or already_linked_elsewhere + + oob_possible = chassis_pair_likely and existing_oob is None + host_possible = chassis_pair_likely and bool( + existing_link + and existing_link["host_id"] + and existing_link["host_id"] != normalized_device_id + and not existing_link.get("oob_id") + ) + existing_oob_from_name = _detect_oob_type_from_name(existing_by_serial.name) + + # --- Compute all values before mutating result --- + oob_candidate_data = None + if oob_possible: + inferred_oob_type = ( + oob_type_from_libre + or _detect_oob_type_from_name( + libre_device.get("hostname") or libre_device.get("sysName") or "" + ) + or "oob" ) - result["serial_action"] = "link" + oob_candidate_data = { + "device": existing_by_serial, + "type": inferred_oob_type, + "version": libre_device.get("version") or None, + "ip": libre_device.get("ip") or None, + } + + promote_to_host_data = None + if host_possible: + promote_to_host_data = { + "existing_libre_id": existing_link["host_id"], + "existing_oob_type": existing_oob_from_name or "oob", + # Included for bulk-collision detection: lets + # detect_bulk_collisions identify which NetBox device + # would be modified without an extra DB round-trip. + "existing_device": existing_by_serial, + } + + # Heuristic default: incoming-OS clearly OOB -> oob; otherwise if the + # existing device's NAME suggests it is the OOB and a host link can be + # demoted, offer promote; otherwise fall back to whichever is feasible. + if oob_type_from_libre and oob_possible: + serial_action_value = "oob_candidate" + elif host_possible and existing_oob_from_name: + serial_action_value = "promote_to_host" + elif oob_possible and host_possible: + # Both feasible but neither heuristic matches strongly -- + # default to oob_candidate (least-destructive), let the user flip. + serial_action_value = "oob_candidate" + elif oob_possible: + serial_action_value = "oob_candidate" + elif host_possible: + serial_action_value = "promote_to_host" 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." + serial_action_value = None + + block_warnings: list = [] + if oob_type_from_libre and existing_oob is not None: + # OOB-typed incoming but existing already has an OOB linked -- + # inform without blocking. No actionable button in this branch. + serial_action_value = "link" + block_warnings.append( + f"Device '{existing_by_serial.name}' already has an OOB controller linked. " + f"Re-import will update the existing OOB entry." ) - result["serial_action"] = "hostname_differs" + elif not oob_possible and not host_possible: + # Neither role is feasible -- fall back to legacy hostname/serial + # warning behaviour so the user still sees a useful message. + if existing_by_serial.name and existing_by_serial.name.lower() == hostname.lower(): + if existing_link and existing_link["host_id"]: + block_warnings.append( + f"Device with same serial and hostname exists as '{existing_by_serial.name}' " + f"(currently linked to LibreNMS device #{existing_link['host_id']})" + ) + else: + block_warnings.append( + f"Device with same serial and hostname exists as '{existing_by_serial.name}' " + f"(not linked to LibreNMS)" + ) + serial_action_value = "link" + else: + block_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." + ) + serial_action_value = "hostname_differs" + + apply_oob_detection_result( + result, + serial_action=serial_action_value, + oob_candidate=oob_candidate_data, + promote_to_host=promote_to_host_data, + serial_role_choice_available=oob_possible and host_possible, + warnings=block_warnings, + ) + + # 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). + # Must happen before the merge-candidates block below so a VM hostname-match + # doesn't fall through to Device-only merge logic. + import_as_vm = result["import_as_vm"] + + # Stage 2 — merge-candidates detection. + # When the hostname-matched device and the serial-matched device are + # DIFFERENT NetBox objects, the two probably represent the same + # physical box (host + OOB) imported as separate entries. Surface + # this as a merge action instead of silently picking one. + try: + _serial_for_pair = (libre_device.get("serial") or "").strip() + if ( + _serial_for_pair + and _serial_for_pair != "-" + and not import_as_vm + and result.get("existing_device") is not None + and result.get("existing_match_type") in ("hostname", "serial") + ): + _hostname_match = ( + result["existing_device"] if result.get("existing_match_type") == "hostname" else None + ) + _serial_match = result["existing_device"] if result.get("existing_match_type") == "serial" else None + # Whichever path landed first, look the other one up too. + if _hostname_match and not _serial_match: + _serial_match = ( + Device.objects.filter(serial=_serial_for_pair).exclude(pk=_hostname_match.pk).first() + ) + elif _serial_match and not _hostname_match and hostname: + _hostname_match = ( + Device.objects.filter(name__iexact=hostname).exclude(pk=_serial_match.pk).first() + ) + + if _hostname_match and _serial_match and _hostname_match.pk != _serial_match.pk: + host_link = _describe_existing_librenms_link(_hostname_match, server_key) + oob_link = _describe_existing_librenms_link(_serial_match, server_key) + # Conservative guard: at least one side must already be linked, + # otherwise this is more likely two unrelated devices that share + # serial data by coincidence (test fixtures, mis-keyed assets). + if (host_link and host_link["host_id"]) or (oob_link and oob_link["host_id"]): + apply_merge_candidates( + result, + host_named={ + "pk": _hostname_match.pk, + "name": _hostname_match.name, + "librenms_link": host_link, + }, + oob_named={ + "pk": _serial_match.pk, + "name": _serial_match.name, + "librenms_link": oob_link, + }, + warning=( + f"Two NetBox devices appear to represent this physical box: " + f"'{_hostname_match.name}' (matches LibreNMS hostname) and " + f"'{_serial_match.name}' (matches chassis serial). " + f"Choose which one to keep and merge the other into it." + ), + ) + except Exception: # pragma: no cover - defensive: never break validation + logger.exception("merge-candidate detection failed") # Check by primary IP (weaker match, IP could be reassigned) - only for devices if not result["existing_device"]: @@ -477,16 +722,43 @@ def validate_device_for_import( 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)" + # Check if this is an OOB candidate via the IP path. + # The OOB controller's IP may already be the device's oob_ip, or the + # LibreNMS device may identify itself as an OOB type (iDRAC/iLO/etc.). + oob_type = normalize_oob_type( + libre_device.get("os", ""), + libre_device.get("hardware", ""), ) - 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"] + is_oob_ip = device.oob_ip_id is not None and existing_ip.pk == device.oob_ip_id + has_primary_ip = bool(device.primary_ip4_id or device.primary_ip6_id) + if oob_type and (is_oob_ip or not has_primary_ip): + existing_oob = get_librenms_oob(device, server_key=server_key) + if existing_oob is None: + result["existing_device"] = device + result["existing_match_type"] = "primary_ip" + result["serial_action"] = "oob_candidate" + result["oob_candidate"] = { + "device": device, + "type": oob_type, + "version": libre_device.get("version") or None, + "ip": libre_device.get("ip") or None, + } + result["can_import"] = False + else: + result["existing_device"] = device + result["existing_match_type"] = "primary_ip" + result["warnings"].append( + f"IP address {primary_ip} already assigned to device '{device.name}' " + f"(OOB already linked)" + ) + result["can_import"] = False + else: + 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 # Validate based on import type (Device or VM) if import_as_vm: diff --git a/netbox_librenms_plugin/import_validation_helpers.py b/netbox_librenms_plugin/import_validation_helpers.py index cf26c6a40..718df466a 100644 --- a/netbox_librenms_plugin/import_validation_helpers.py +++ b/netbox_librenms_plugin/import_validation_helpers.py @@ -137,6 +137,77 @@ def remove_validation_issue(validation: dict, keyword: str) -> None: validation["issues"] = [issue for issue in validation["issues"] if keyword.lower() not in issue.lower()] +def apply_oob_detection_result( + result: dict, + *, + serial_action: "str | None", + oob_candidate: "dict | None", + promote_to_host: "dict | None", + serial_role_choice_available: bool, + warnings: "list | None" = None, +) -> None: + """Apply OOB/promote-to-host serial detection results to the validation dict. + + Call this after computing all OOB/promote-to-host flags from the LibreNMS + and NetBox data. All mutations to ``result["oob_candidate"]``, + ``result["promote_to_host"]``, ``result["serial_action"]``, + ``result["serial_role_choice_available"]``, and their associated warnings + are routed through here so the mutation pattern stays consistent and + testable independently of the DB-heavy computation in device_operations. + + Args: + result: Validation dict produced by validate_device_for_import() + serial_action: The resolved action string, or None + oob_candidate: Dict {device, type, version, ip} when OOB role is available + promote_to_host: Dict {existing_libre_id, existing_oob_type, existing_device} + when host-promotion is available + serial_role_choice_available: True when both oob_candidate and + promote_to_host are feasible and the UI should offer a toggle + warnings: Optional list of warning strings to append to result["warnings"] + """ + result["serial_action"] = serial_action + result["oob_candidate"] = oob_candidate + result["promote_to_host"] = promote_to_host + result["serial_role_choice_available"] = serial_role_choice_available + for warning in warnings or []: + result["warnings"].append(warning) + + +def apply_merge_candidates( + result: dict, + *, + host_named: dict, + oob_named: dict, + warning: str, +) -> None: + """Apply merge-candidates detection results to the validation dict. + + Called when the hostname-matched and serial-matched NetBox devices are + different objects and at least one already has a LibreNMS linkage, + indicating they likely represent the two sides of a single physical box. + + Sets ``serial_action`` to ``"merge_netbox_devices"``, populates + ``merge_candidates``, sets ``can_import`` to False, and appends the + supplied warning so callers do not need to know the dict shape. + + Args: + result: Validation dict produced by validate_device_for_import() + host_named: Dict {pk, name, librenms_link} for the hostname-matched device + oob_named: Dict {pk, name, librenms_link} for the serial-matched device + warning: Warning string describing the merge situation + """ + result["serial_action"] = "merge_netbox_devices" + result["merge_candidates"] = { + "host_named": host_named, + "oob_named": oob_named, + } + result["can_import"] = False + result["oob_candidate"] = None + result["promote_to_host"] = None + result["serial_role_choice_available"] = False + result["warnings"].append(warning) + + def recalculate_validation_status(validation: dict, is_vm: bool = False) -> None: """ Recalculate can_import and is_ready flags based on current validation state. diff --git a/netbox_librenms_plugin/librenms_api.py b/netbox_librenms_plugin/librenms_api.py index 86eba28f3..d5d649114 100644 --- a/netbox_librenms_plugin/librenms_api.py +++ b/netbox_librenms_plugin/librenms_api.py @@ -255,15 +255,12 @@ def get_librenms_id(self, obj): def _normalize_librenms_id(value): """Coerce a raw LibreNMS ID value to int or None. - Booleans are rejected because bool is a subclass of int in Python, - so int(True) silently becomes 1 — a valid-looking device ID. + Thin wrapper around :func:`netbox_librenms_plugin.utils.coerce_librenms_id` + kept for back-compat with internal callers in this module. """ - if value is None or isinstance(value, bool): - return None - try: - return int(value) - except (ValueError, TypeError): - return None + from netbox_librenms_plugin.utils import coerce_librenms_id + + return coerce_librenms_id(value) def _get_cache_key(self, obj): """ 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 edfda206a..c152cc080 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 @@ -1203,7 +1203,9 @@ // the outer HTMX modal. Buttons inside nested modals (e.g. the // Promote-to-host modal rendered inside #htmx-modal-content) // must be left for Bootstrap's own dismiss handler so they - // close the inner modal, not the outer one. + // close the inner modal, not the outer one. We also avoid + // preventDefault here so form submit buttons that happen to + // carry data-bs-dismiss="modal" in nested modals still submit. const nearestModal = dismissTrigger.closest('.modal'); if (nearestModal === modalElement) { event.preventDefault(); @@ -1212,6 +1214,50 @@ } }); + // Refresh the validation modal in place (used after promote / OOB + // attach actions that mutate device link state but should leave the + // user inside the modal so they can see the new state). Also closes + // any nested modals (e.g. the Promote-to-host pick modal) before + // re-fetching so the user sees the refreshed validation directly. + document.body.addEventListener('validationRefresh', function (event) { + // Close any nested Bootstrap modals currently open inside the + // outer validation modal content. Use the same detection order as + // the rest of the plugin: bare `bootstrap` global first (preferred), + // then `window.bootstrap` as fallback, then plain DOM toggling. + document.querySelectorAll('#htmx-modal-content .modal.show').forEach(function (nested) { + try { + if (typeof bootstrap !== 'undefined' && bootstrap.Modal) { + bootstrap.Modal.getOrCreateInstance(nested).hide(); + } else if (window.bootstrap && window.bootstrap.Modal) { + window.bootstrap.Modal.getOrCreateInstance(nested).hide(); + } else { + nested.classList.remove('show'); + nested.style.display = 'none'; + nested.setAttribute('aria-hidden', 'true'); + } + } catch (err) { + // Swallow - we still want to refresh the validation panel. + } + }); + + const deviceId = event.detail && (event.detail.deviceId || event.detail.device_id); + if (!deviceId) { + return; + } + const btn = document.querySelector( + 'tr#device-row-' + deviceId + ' button[hx-get*="/validation/' + deviceId + '/"]' + ); + if (btn) { + // htmx registers a delegated click handler on document, so a + // synthetic MouseEvent click on the row's "View details" + // button re-triggers the validation GET and swaps the new + // content into #htmx-modal-content. We cannot call + // `htmx.trigger()` directly because NetBox does not expose + // the htmx global to user scripts. + btn.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true, view: window})); + } + }); + // Handle backdrop clicks for HTMX modal modalElement?.addEventListener('click', function (event) { if (event.target === modalElement) { diff --git a/netbox_librenms_plugin/tables/cables.py b/netbox_librenms_plugin/tables/cables.py index cad4660b3..5bf3f479c 100644 --- a/netbox_librenms_plugin/tables/cables.py +++ b/netbox_librenms_plugin/tables/cables.py @@ -61,9 +61,14 @@ def render_remote_device(self, value, record): def render_local_port(self, value, record): """Render local port name as a link if URL is available.""" + oob_badge = ( + format_html(' OOB') + if record.get("_source") == "oob" + else "" + ) if url := record.get("local_port_url"): - return format_html('{}', url, value) - return value + return format_html('{}{}', url, value, oob_badge) + return format_html("{}{}", value or "", oob_badge) def render_remote_port(self, value, record): """Render remote port name as a link if URL is available.""" diff --git a/netbox_librenms_plugin/tables/device_status.py b/netbox_librenms_plugin/tables/device_status.py index cb71b7ef1..af661e356 100644 --- a/netbox_librenms_plugin/tables/device_status.py +++ b/netbox_librenms_plugin/tables/device_status.py @@ -467,11 +467,47 @@ def render_actions(self, value, record): 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) + is_oob_candidate = serial_action == "oob_candidate" + is_oob_linked = match_type == "librenms_oob" + has_actions = match_type == "hostname" or ( + match_type == "serial" and serial_action is not None and not is_oob_candidate + ) 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: + existing_link = validation.get("existing_librenms_link") or {} + paired_oob_id = existing_link.get("oob_id") + paired_host_id = existing_link.get("host_id") + paired_oob_type = existing_link.get("oob_type") or "OOB" + + def _coerce_pair_id(value): + try: + return int(value) + except (TypeError, ValueError): + return None + + if is_oob_candidate: + btn_class = "btn-outline-purple" + btn_icon = "mdi-chip" + btn_label = " OOB" + btn_title = "Add as OOB controller" + elif is_oob_linked: + # This LibreNMS row is the OOB half of an existing pair. + btn_class = "btn-outline-info" + btn_icon = "mdi-chip" + btn_label = " OOB" + if paired_host_id is not None: + try: + paired_host_id_int = int(paired_host_id) + except (TypeError, ValueError): + paired_host_id_int = None + if paired_host_id_int is not None: + btn_title = f"Linked as OOB controller (paired host: LibreNMS #{paired_host_id_int})" + else: + btn_title = "Linked as OOB controller" + else: + btn_title = "Linked as OOB controller" + elif has_mismatch: btn_class = "btn-outline-danger" btn_icon = "mdi-alert-circle" btn_label = " Conflict" @@ -491,6 +527,28 @@ def render_actions(self, value, record): btn_icon = "mdi-database-alert" btn_label = " Legacy ID" btn_title = "View legacy ID migration details" + elif ( + match_type == "librenms_id" + and paired_oob_id is not None + and _coerce_pair_id(paired_oob_id) != _coerce_pair_id(paired_host_id) + ): + # This LibreNMS row is the host half of an existing host/OOB + # pair. Render it with the same info-tinted styling as the OOB + # row so the user sees them as one paired device rather than + # two unrelated statuses (one green "ready", one blue "OOB"). + btn_class = "btn-outline-info" + btn_icon = "mdi-server-network" + btn_label = " Host" + # paired_oob_type comes from a user-editable custom field + # (librenms_id..oob.type) and is only string-type-checked, + # not sanitised, on the read path. Escape before interpolating + # into the title attribute to prevent stored XSS. + # paired_oob_id may be a string-digit from older serialization; + # coerce to int so display is clean. + _oob_id_fmt = ( + _coerce_pair_id(paired_oob_id) if _coerce_pair_id(paired_oob_id) is not None else paired_oob_id + ) + btn_title = f"Linked as host (paired OOB: LibreNMS #{escape(str(_oob_id_fmt))}, {escape(paired_oob_type or '')})" else: btn_class = "btn-outline-success" btn_icon = "mdi-check-circle" diff --git a/netbox_librenms_plugin/tables/interfaces.py b/netbox_librenms_plugin/tables/interfaces.py index d99d2a7bf..5a3da85a0 100644 --- a/netbox_librenms_plugin/tables/interfaces.py +++ b/netbox_librenms_plugin/tables/interfaces.py @@ -300,7 +300,15 @@ def render_speed(self, value, record): def render_name(self, value, record): """Render interface name with appropriate styling based on comparison with NetBox""" - return self._render_field(value, record, self.interface_name_field, "name") + rendered = self._render_field(value, record, self.interface_name_field, "name") + badges = "" + if record.get("_source") == "oob": + badges += 'OOB' + if record.get("_dedup_conflict"): + badges += 'Shared LOM' + if badges: + return format_html("{}{}", rendered, mark_safe(badges)) + return rendered def _get_interface_status_display(self, enabled, record): """ diff --git a/netbox_librenms_plugin/tables/modules.py b/netbox_librenms_plugin/tables/modules.py index b3f7f72f8..9b99b688b 100644 --- a/netbox_librenms_plugin/tables/modules.py +++ b/netbox_librenms_plugin/tables/modules.py @@ -147,16 +147,22 @@ def render_name(self, value, record): rendered_name = display_name depth = record.get("depth", 0) + oob_badge = ( + format_html(' OOB') + if record.get("_source") == "oob" + else "" + ) if depth == 0: - return rendered_name + return format_html("{}{}", rendered_name, oob_badge) # Build visual tree prefix based on nesting depth padding_px = depth * 20 prefix = "└─ " return format_html( - '{}{}', + '{}{}{}', padding_px, prefix, rendered_name, + oob_badge, ) def render_model(self, value, record): diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html index c8a51bdca..cd0cfc951 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html @@ -311,6 +311,7 @@
+ hx-include="#use-sysname-toggle, #strip-domain-toggle, #auto-create-ipam-toggle"> {% csrf_token %} @@ -165,7 +165,7 @@
+ hx-include="#use-sysname-toggle, #strip-domain-toggle, #auto-create-ipam-toggle"> {% csrf_token %} @@ -342,8 +342,8 @@
{% endif %} {% include "netbox_librenms_plugin/htmx/_platform_manage_icon.html" %} {% endif %} - - {{ libre_device.os|default:"—" }} + + {{ libre_device.os|default:"—" }} {% if validation.import_as_vm and not validation.existing_device or validation.existing_device and existing_device_model_name == "virtualmachine" %} @@ -403,7 +403,84 @@
{# Status & Actions #} {% if validation.existing_device %} - {% if validation.existing_match_type == 'librenms_id' %} + {% if validation.serial_action == 'merge_netbox_devices' %} + {# Stage 2: two NetBox devices appear to represent the same physical box. #} +
+ Two NetBox devices + + — A hostname match and a chassis-serial match resolved to two different NetBox + devices. Pick which one to keep; the other will be marked as merged. + +
+
+
+ + Pick the device to keep (winner). + The other one becomes the donor and is absorbed. +
+
    +
  • Moved to winner: LibreNMS link (host id, OOB id, OOB type) and OOB IP (only if the winner has no OOB IP yet).
  • +
  • Stays on donor: interfaces, cables, primary IP. Re-home them later from the donor's "Migrated to ..." tab.
  • +
+
+ + The donor device is not deleted. After you have re-homed its + child objects, you can delete it from NetBox manually if no longer needed. +
+
+ + {% csrf_token %} + + +
+
Choose the winner (kept). The other becomes the donor (absorbed):
+ {% with host=validation.merge_candidates.host_named oob=validation.merge_candidates.oob_named %} +
+ + +
+
+ + +
+ {% endwith %} +
+ + + + + {% elif validation.existing_match_type == 'librenms_id' %}
{% if existing_id_servers %} {% for srv in existing_id_servers %} @@ -495,7 +572,7 @@
+ hx-include="#use-sysname-toggle, #strip-domain-toggle, #auto-create-ipam-toggle"> {% csrf_token %} @@ -525,26 +602,302 @@
{% elif validation.existing_match_type == 'serial' %}
- Serial match + {% if validation.serial_action == 'oob_candidate' %} + OOB Detected + {% elif validation.serial_action == 'promote_to_host' %} + Host Detected + {% else %} + Serial match + {% endif %} {% if validation.serial_action == 'hostname_differs' %} Name differs {% elif validation.serial_action == 'link' %} Name match {% endif %} - {% if validation.device_type_mismatch %} + {% if validation.device_type_mismatch and validation.serial_action != 'oob_candidate' and validation.serial_action != 'promote_to_host' %} Type mismatch {% endif %} — Exists as - {{ validation.existing_device.name }}, - not linked to LibreNMS. + {{ validation.existing_device.name }}{% if validation.existing_librenms_link.host_id %}, + currently linked to LibreNMS device #{{ validation.existing_librenms_link.host_id }}{% if validation.existing_librenms_link.oob_id %} (OOB #{{ validation.existing_librenms_link.oob_id }}{% if validation.existing_librenms_link.oob_type %}, {{ validation.existing_librenms_link.oob_type }}{% endif %}){% endif %}.{% else %}, + not linked to LibreNMS.{% endif %}
+ {% if validation.serial_role_choice_available %} +
+ This LibreNMS device is the: +
+ + + + +
+ (heuristic guess pre-selected -- flip if wrong) +
+ {% endif %} + {% if validation.oob_candidate %} +
+
+
+ + OOB attach effect on + {{ validation.existing_device.name }} + +
+ + + + + + + + + + + + + + + + + + + + + + + +
SlotBeforeAfter
Host{% if validation.existing_librenms_link.host_id %}#{{ validation.existing_librenms_link.host_id }}{% else %}--{% endif %}{% if validation.existing_librenms_link.host_id %}#{{ validation.existing_librenms_link.host_id }} (unchanged){% else %}-- (unchanged){% endif %}
OOB--#{{ libre_device.device_id }} ({{ validation.oob_candidate.type }}{% if validation.oob_candidate.ip %}, {{ validation.oob_candidate.ip }}{% endif %})
+
+ + Existing device is updated in place -- no new NetBox device is created. +
+
+
+ + {% csrf_token %} + + + + +
+
+ {% endif %} + {% if validation.promote_to_host %} +
+
+
+ + Promote effect on + {{ validation.existing_device.name }} + +
+ + + + + + + + + + + + + + + + + + + + + + + +
SlotBeforeAfter
Host#{{ validation.promote_to_host.existing_libre_id }}#{{ libre_device.device_id }} ({{ validation.resolved_name|default:libre_device.sysName|default:libre_device.hostname }})
OOB--#{{ validation.promote_to_host.existing_libre_id }} ({{ validation.promote_to_host.existing_oob_type }})
+
+ + Existing device is updated in place -- no new NetBox device is created. +
+
+
+ + + + + +
+
+ {% endif %} + {% if validation.serial_action == 'link' or validation.serial_action == 'hostname_differs' %}
+ hx-include="#use-sysname-toggle, #strip-domain-toggle, #auto-create-ipam-toggle"> {% csrf_token %} @@ -553,7 +906,7 @@
{% endif %} @@ -569,8 +922,39 @@
{% endif %}
+ {% endif %} {% elif validation.existing_match_type == 'primary_ip' %} + {% if validation.serial_action == 'oob_candidate' %} +
+ OOB Detected + + — Exists as + {{ validation.existing_device.name }} + (matched via IP {{ libre_device.ip }}). + +
+
+ + LibreNMS device {{ libre_device.sysName|default:libre_device.hostname }} + appears to be an OOB management controller + ({{ validation.oob_candidate.type }} + {% if validation.oob_candidate.ip %}— {{ validation.oob_candidate.ip }}{% endif %}). +
+
+
+ {% csrf_token %} + + + +
+
+ {% else %}
IP match @@ -579,6 +963,16 @@
Consider adding LibreNMS ID manually.
+ {% endif %} + + {% elif validation.existing_match_type == 'librenms_oob' %} +
+ OOB linked + + — LibreNMS ID {{ libre_device.device_id }} is already linked as the OOB controller for + {{ validation.existing_device.name }}. + +
{% else %}
@@ -635,7 +1029,7 @@
class="btn btn-primary btn-sm" target="_blank" rel="noopener noreferrer"> View in NetBox - {% if validation.existing_match_type == 'librenms_id' %} + {% if validation.existing_match_type == 'librenms_id' or validation.existing_match_type == 'librenms_oob' %} Full Sync Page @@ -647,3 +1041,59 @@
Close
+ + diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html index adba2892d..ea36636c2 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html @@ -35,6 +35,63 @@ {% block content %} +{% if migrated_to_marker %} + +{% endif %} + {% if all_server_mappings %}
diff --git a/netbox_librenms_plugin/tests/test_collisions.py b/netbox_librenms_plugin/tests/test_collisions.py new file mode 100644 index 000000000..60c7e9164 --- /dev/null +++ b/netbox_librenms_plugin/tests/test_collisions.py @@ -0,0 +1,202 @@ +"""Unit tests for ``netbox_librenms_plugin.import_utils.collisions``.""" + +from netbox_librenms_plugin.import_utils.collisions import detect_bulk_collisions + + +class Device: + """Minimal NetBox Device stand-in for collision tests. + + The class name matters: ``detect_bulk_collisions`` keys collision + buckets on ``type(obj).__name__`` so a Device and a VirtualMachine + sharing an integer pk are not falsely grouped. A ``SimpleNamespace`` + would bucket as ``"SimpleNamespace"`` and never match the pk-only + merge/promote paths, which assume ``"Device"``. + """ + + def __init__(self, pk, name): + self.pk = pk + self.name = name + + +class VirtualMachine: + """Minimal VirtualMachine stand-in — same shape as Device but different class name.""" + + def __init__(self, pk, name): + self.pk = pk + self.name = name + + +def _row(libre_id, hostname, validation): + return {"device_id": libre_id, "device_name": hostname, "validation": validation} + + +def test_no_devices_returns_empty(): + assert detect_bulk_collisions([]) == [] + assert detect_bulk_collisions(None) == [] + + +def test_no_overlap_returns_empty(): + devices = [ + _row(1, "alpha", {"existing_device": Device(pk=10, name="nb-alpha")}), + _row(2, "beta", {"existing_device": Device(pk=11, name="nb-beta")}), + ] + assert detect_bulk_collisions(devices) == [] + + +def test_host_and_oob_collide_on_same_nb_device(): + nb_device = Device(pk=42, name="srv-01") + devices = [ + _row(100, "host-row", {"existing_device": nb_device}), + _row(101, "oob-row", {"oob_candidate": {"device": nb_device, "type": "idrac"}}), + ] + groups = detect_bulk_collisions(devices) + assert len(groups) == 1 + g = groups[0] + assert g["nb_device_pk"] == 42 + assert g["nb_device_name"] == "srv-01" + rows = {r["device_id"]: r for r in g["librenms_rows"]} + assert rows[100]["role"] == "host" + assert rows[101]["role"] == "oob" + + +def test_two_promote_targets_to_same_nb_device_collide(): + nb_device = Device(pk=7, name="core-sw") + devices = [ + _row(200, "row-a", {"promote_to_host": {"existing_device": nb_device}}), + _row(201, "row-b", {"promote_to_host": {"existing_device": nb_device}}), + ] + groups = detect_bulk_collisions(devices) + assert len(groups) == 1 + assert {r["device_id"] for r in groups[0]["librenms_rows"]} == {200, 201} + + +def test_merge_candidates_overlap_with_direct_existing(): + devices = [ + _row( + 300, + "row-merge", + { + "merge_candidates": { + "host_named": {"pk": 99, "name": "host-side"}, + "oob_named": {"pk": 100, "name": "oob-side"}, + } + }, + ), + _row(301, "row-direct", {"existing_device": Device(pk=99, name="host-side")}), + ] + groups = detect_bulk_collisions(devices) + assert len(groups) == 1 + assert groups[0]["nb_device_pk"] == 99 + rows = {r["device_id"] for r in groups[0]["librenms_rows"]} + assert rows == {300, 301} + + +def test_promote_via_existing_device_field_collides_with_direct_match(): + """promote_to_host carries existing_device; collision fires against a direct existing_device row.""" + nb_device = Device(pk=55, name="shared") + devices = [ + _row(400, "row-a", {"promote_to_host": {"existing_device": nb_device, "existing_libre_id": 9}}), + _row(401, "row-b", {"existing_device": nb_device}), + ] + groups = detect_bulk_collisions(devices) + assert len(groups) == 1 + assert groups[0]["nb_device_pk"] == 55 + assert groups[0]["nb_device_name"] == "shared" + + +def test_three_way_collision(): + nb_device = Device(pk=8, name="busy") + devices = [ + _row(500, "a", {"existing_device": nb_device}), + _row(501, "b", {"oob_candidate": {"device": nb_device}}), + _row(502, "c", {"promote_to_host": {"existing_device": nb_device}}), + ] + groups = detect_bulk_collisions(devices) + assert len(groups) == 1 + assert {r["device_id"] for r in groups[0]["librenms_rows"]} == {500, 501, 502} + + +def test_same_libre_row_under_multiple_roles_does_not_collide_with_itself(): + """A single LibreNMS row whose validation lists the same NB pk under + multiple roles must NOT be reported as a collision — collisions + require at least two *distinct* LibreNMS device_ids.""" + nb_device = Device(pk=12, name="solo") + devices = [ + _row( + 600, + "lonely", + { + "existing_device": nb_device, + "oob_candidate": {"device": nb_device}, + }, + ), + ] + assert detect_bulk_collisions(devices) == [] + + +def test_invalid_pks_are_skipped(): + devices = [ + _row(700, "a", {"existing_device": Device(pk=None, name="bad")}), + _row(701, "b", {"existing_device": Device(pk="not-int", name="worse")}), + ] + assert detect_bulk_collisions(devices) == [] + + +def test_invalid_libre_id_is_skipped(): + """Rows with non-int device_id are dropped silently.""" + nb_device = Device(pk=20, name="nb") + devices = [ + _row("not-int", "weird", {"existing_device": nb_device}), + _row(800, "ok", {"existing_device": nb_device}), + ] + # Only one valid row -> no collision. + assert detect_bulk_collisions(devices) == [] + + +def test_groups_sorted_by_nb_pk_for_stable_render(): + nb_high = Device(pk=999, name="z") + nb_low = Device(pk=1, name="a") + devices = [ + _row(10, "x", {"existing_device": nb_high}), + _row(11, "y", {"existing_device": nb_high}), + _row(20, "p", {"existing_device": nb_low}), + _row(21, "q", {"existing_device": nb_low}), + ] + groups = detect_bulk_collisions(devices) + assert [g["nb_device_pk"] for g in groups] == [1, 999] + + +def test_row_roles_are_joined_when_same_libre_row_targets_pk_via_multiple_paths(): + """When LibreNMS row R touches NB pk P via both 'host' and + 'merge_host_named' (rare but possible during transitional states), + its row entry in the collision group should list both roles.""" + devices = [ + _row( + 900, + "dual", + { + "existing_device": Device(pk=77, name="shared"), + "merge_candidates": { + "host_named": {"pk": 77, "name": "shared"}, + "oob_named": {"pk": 78, "name": "other"}, + }, + }, + ), + _row(901, "neighbour", {"existing_device": Device(pk=77, name="shared")}), + ] + groups = detect_bulk_collisions(devices) + assert len(groups) == 1 + rows = {r["device_id"]: r["role"] for r in groups[0]["librenms_rows"]} + roles_900 = {role.strip() for role in rows[900].split(",")} + assert {"host", "merge_host_named"} <= roles_900 + assert rows[901] == "host" + + +def test_device_and_vm_with_same_pk_do_not_collide(): + """Device and VirtualMachine sharing a pk must NOT be reported as a collision + because detect_bulk_collisions keys buckets on (model_name, pk).""" + devices = [ + _row(1000, "device-row", {"existing_device": Device(pk=42, name="srv")}), + _row(1001, "vm-row", {"existing_device": VirtualMachine(pk=42, name="srv")}), + ] + assert detect_bulk_collisions(devices) == [] diff --git a/netbox_librenms_plugin/tests/test_coverage_actions.py b/netbox_librenms_plugin/tests/test_coverage_actions.py index 36da3e5f8..c594ee6d5 100644 --- a/netbox_librenms_plugin/tests/test_coverage_actions.py +++ b/netbox_librenms_plugin/tests/test_coverage_actions.py @@ -56,8 +56,8 @@ def test_validation_error_returns_400(self): device.full_clean.side_effect = ValidationError({"name": ["This field is required."]}) response = _save_device(device) - assert response.status_code == 200 - assert response.headers.get("HX-Reswap") == "none" + assert response.status_code == 400 + assert b"Validation error" in response.content def test_integrity_error_returns_409(self): from django.db import IntegrityError @@ -69,8 +69,8 @@ def test_integrity_error_returns_409(self): device.save.side_effect = IntegrityError("duplicate key") response = _save_device(device) - assert response.status_code == 200 - assert response.headers.get("HX-Reswap") == "none" + assert response.status_code == 409 + assert b"Integrity error" in response.content def test_success_returns_none(self): from netbox_librenms_plugin.views.imports.actions import _save_device @@ -448,6 +448,55 @@ def test_render_device_row_calls_render(self, mock_render): assert "device_import_row.html" in mock_render.call_args[0][1] +class TestAttachMessagesOob: + """Tests for the _attach_messages_oob helper.""" + + def test_returns_none_when_response_is_none(self): + from netbox_librenms_plugin.views.imports.actions import _attach_messages_oob + + assert _attach_messages_oob(None, MagicMock()) is None + + def test_skips_response_without_bytes_content(self): + """When .content is a MagicMock or similar non-bytes value, skip cleanly.""" + from netbox_librenms_plugin.views.imports.actions import _attach_messages_oob + + response = MagicMock() + response.content = MagicMock() # not bytes / bytearray + result = _attach_messages_oob(response, MagicMock()) + assert result is response # returned unchanged + + def test_appends_rendered_messages_to_bytes_content(self): + from django.http import HttpResponse + + from netbox_librenms_plugin.views.imports.actions import _attach_messages_oob + + response = HttpResponse(b"row html") + with patch( + "netbox_librenms_plugin.views.imports.actions.render_to_string", + return_value='
', + ) as mock_render: + result = _attach_messages_oob(response, MagicMock()) + + mock_render.assert_called_once() + assert b'
row html") + + def test_swallows_render_errors(self): + from django.http import HttpResponse + + from netbox_librenms_plugin.views.imports.actions import _attach_messages_oob + + response = HttpResponse(b"row html") + original = response.content + with patch( + "netbox_librenms_plugin.views.imports.actions.render_to_string", + side_effect=RuntimeError("db not available"), + ): + result = _attach_messages_oob(response, MagicMock()) + + assert result.content == original + + class TestDeviceValidationDetailsView: """Tests for DeviceValidationDetailsView (lines 477-822).""" @@ -2392,7 +2441,7 @@ class TestSaveDevicePath: """Test _save_device IntegrityError and ValidationError paths (line 168).""" def test_save_device_validation_error(self): - """Lines 50-52: ValidationError during save.""" + """ValidationError during full_clean → 400 response.""" from netbox_librenms_plugin.views.imports.actions import _save_device from django.core.exceptions import ValidationError as DjangoValidationError @@ -2401,11 +2450,11 @@ def test_save_device_validation_error(self): result = _save_device(mock_device) assert result is not None - assert result.status_code == 200 - assert result.headers.get("HX-Reswap") == "none" + assert result.status_code == 400 + assert b"Validation error" in result.content def test_save_device_integrity_error(self): - """Lines 54-56: IntegrityError during save.""" + """IntegrityError during save → 409 response.""" from netbox_librenms_plugin.views.imports.actions import _save_device from django.db import IntegrityError @@ -2415,8 +2464,8 @@ def test_save_device_integrity_error(self): result = _save_device(mock_device) assert result is not None - assert result.status_code == 200 - assert result.headers.get("HX-Reswap") == "none" + assert result.status_code == 409 + assert b"Integrity error" in result.content def test_should_enable_vc_detection_when_cached(self): """Line 168: VC data already cached → returns True.""" @@ -4080,3 +4129,340 @@ def get_side_effect(k, d=None): view.post(request) mock_redirect.assert_called() + + +class TestBulkImportConfirmCollisions: + """Tests for Stage 3 collision-blocking behaviour.""" + + def _make_view(self): + from netbox_librenms_plugin.views.imports.actions import BulkImportConfirmView + + view = object.__new__(BulkImportConfirmView) + view._librenms_api = _make_api() + return view + + def _run_with_two_devices(self, validation_a, validation_b): + """Drive BulkImportConfirmView.post with two LibreNMS rows whose + validations are stubbed to whatever the test wants. Returns the + actual response object returned by view.post().""" + view = self._make_view() + request = _make_request(post={"select": ["1", "2"]}) + request.POST.getlist = MagicMock(return_value=["1", "2"]) + request.GET = MagicMock() + request.GET.get = MagicMock(return_value=None) + + libre_devices = { + 1: {"device_id": 1, "hostname": "alpha"}, + 2: {"device_id": 2, "hostname": "beta"}, + } + validations = {1: validation_a, 2: validation_b} + + def fake_validate(libre_device, **_kwargs): + return validations[libre_device["device_id"]] + + with patch.object(view, "require_write_permission", return_value=None): + with patch( + "netbox_librenms_plugin.views.imports.actions.fetch_device_with_cache", + side_effect=lambda did, _api: libre_devices.get(did), + ): + with patch( + "netbox_librenms_plugin.views.imports.actions.extract_device_selections", + return_value={"cluster_id": None, "role_id": None, "rack_id": None}, + ): + with patch( + "netbox_librenms_plugin.views.imports.actions.validate_device_for_import", + side_effect=fake_validate, + ): + with patch( + "netbox_librenms_plugin.views.imports.actions.resolve_naming_preferences", + return_value=(True, False), + ): + with patch( + "netbox_librenms_plugin.views.imports.actions.render", + ) as mock_render: + mock_render.side_effect = lambda req, tpl, ctx, status=200: MagicMock( + status_code=status, + template_name=tpl, + context=ctx, + ) + response = view.post(request) + return response + + def test_collision_path_renders_collision_template(self): + from types import SimpleNamespace + + nb_device = SimpleNamespace(pk=42, name="srv-collide") + validation_a = { + "status": "importable", + "resolved_name": "alpha", + "virtual_chassis": {}, + "existing_device": nb_device, + } + validation_b = { + "status": "importable", + "resolved_name": "beta", + "virtual_chassis": {}, + "oob_candidate": {"device": nb_device, "type": "idrac"}, + } + response = self._run_with_two_devices(validation_a, validation_b) + # Collision modal is an interstitial swapped into #htmx-modal-content, + # so it must render at 200 -- a non-2xx status makes HTMX skip the swap. + assert response is not None, "view.post returned None instead of a rendered response" + assert "bulk_import_collision.html" in response.template_name + assert response.status_code == 200 + assert len(response.context["collisions"]) == 1 + assert response.context["collisions"][0]["nb_device_pk"] == 42 + + def test_clean_batch_renders_normal_confirm_template(self): + from types import SimpleNamespace + + validation_a = { + "status": "importable", + "resolved_name": "alpha", + "virtual_chassis": {}, + "existing_device": SimpleNamespace(pk=1, name="nb-a"), + } + validation_b = { + "status": "importable", + "resolved_name": "beta", + "virtual_chassis": {}, + "existing_device": SimpleNamespace(pk=2, name="nb-b"), + } + response = self._run_with_two_devices(validation_a, validation_b) + assert response is not None, "view.post returned None instead of a rendered response" + assert "bulk_import_confirm.html" in response.template_name + # Default render() status is 200. + assert response.status_code == 200 + + +# --------------------------------------------------------------------------- +# AddAsOOBView / PromoteToHostView — generic "oob" sentinel regression tests +# --------------------------------------------------------------------------- + + +class TestAddAsOOBViewGenericSentinel: + """AddAsOOBView must not return HTTP 400 when oob_candidate.type == "oob". + + Regression for the bug where the detection layer produced type="oob" as a + sentinel (hostname mismatch, no OOB keywords in names) but set_librenms_oob + rejected "oob" with ValueError, causing every non-keyword device to fail. + + Per testing conventions the submit-path behavior is tested at the utility + layer (set_librenms_oob) rather than by driving the full view. + """ + + def test_generic_oob_sentinel_accepted_by_set_librenms_oob(self): + """set_librenms_oob must not raise ValueError for oob_type='oob'. + + This is the direct root cause: AddAsOOBView calls + set_librenms_oob(..., oob_type=oob_candidate["type"]) where the + candidate type may be the detection-layer sentinel "oob". + """ + from netbox_librenms_plugin.utils import get_librenms_oob, set_librenms_oob + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"default": {"id": 10}}} + obj.cf = obj.custom_field_data + + # Previously this raised ValueError("does not match any known OOB type") + # → AddAsOOBView returned HTTP 400 "Invalid OOB data: ..." + set_librenms_oob(obj, 55, "default", oob_type="oob") + result = get_librenms_oob(obj, "default") + assert result is not None + assert result["type"] == "oob" + + def test_sentinel_from_detection_layer_flows_to_storage(self): + """The three-layer fallback in device_operations produces oob_type='oob' + when neither the LibreNMS OS field nor either device name contains an OOB + keyword. That value must store without error. + """ + from netbox_librenms_plugin.utils import set_librenms_oob + + # Simulate: oob_type_from_libre=None, _detect_oob_type_from_name(...)=None + # → inferred_oob_type = "oob" (device_operations.py line ~563) + oob_type_from_libre = None + detected_from_hostname = None + inferred_oob_type = oob_type_from_libre or detected_from_hostname or "oob" + assert inferred_oob_type == "oob" + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"default": {"id": 99}}} + + # Must not raise + set_librenms_oob(obj, 42, "default", oob_type=inferred_oob_type) + assert obj.custom_field_data["librenms_id"]["default"]["oob"]["id"] == 42 + + +class TestPromoteToHostViewGenericSentinel: + """PromoteToHostView must not return HTTP 400 when existing_oob_type == "oob". + + Regression for the same sentinel bug: when the existing device's name has no + OOB keyword, promote_to_host["existing_oob_type"] = "oob" (device_operations.py + line ~574), which was rejected by set_librenms_oob. + """ + + def test_promote_generic_oob_sentinel_accepted_by_set_librenms_oob(self): + """Promote path: set_librenms_oob with oob_type='oob' must not raise.""" + from netbox_librenms_plugin.utils import set_librenms_oob + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"default": {"id": 10}}} + + # Simulate: existing_oob_from_name = None + # → promote["existing_oob_type"] = None or "oob" = "oob" + existing_oob_type = None or "oob" + assert existing_oob_type == "oob" + + # Previously: ValueError("oob_type 'oob' does not match any known OOB type") + # → PromoteToHostView returned HTTP 400 "Invalid promotion data: ..." + set_librenms_oob(obj, 7, "default", oob_type=existing_oob_type) + assert obj.custom_field_data["librenms_id"]["default"]["oob"]["type"] == "oob" + + +class TestAddAsOOBViewPost: + """View-level tests for AddAsOOBView.post() — HTTP interface + OOB sentinel regression.""" + + def _make_view(self): + from netbox_librenms_plugin.views.imports.actions import AddAsOOBView + + view = object.__new__(AddAsOOBView) + view.kwargs = {} + view._librenms_api = _make_api() + + # Default: write permission granted + view.require_write_permission = MagicMock(return_value=None) + # Default: object permissions granted + view.require_object_permissions = MagicMock(return_value=None) + return view + + def test_missing_existing_device_id_returns_htmx_error(self): + """POST without existing_device_id returns HTMX error.""" + view = self._make_view() + request = _make_request(post={}) + + response = view.post(request, device_id=1) + + assert response.status_code == 200 + assert b"Missing existing_device_id" in response.content + + def test_write_permission_denied_returns_error(self): + """When write permission is denied, view returns that error immediately.""" + from django.http import HttpResponse + + view = self._make_view() + perm_error = HttpResponse("Forbidden", status=403) + view.require_write_permission = MagicMock(return_value=perm_error) + + request = _make_request(post={"existing_device_id": "1"}) + response = view.post(request, device_id=1) + + assert response.status_code == 403 + + def test_invalid_existing_device_id_returns_htmx_error(self): + """POST with a non-integer existing_device_id returns HTMX error.""" + view = self._make_view() + request = _make_request(post={"existing_device_id": "not-a-number"}) + + with patch("dcim.models.Device") as mock_device: + mock_device.DoesNotExist = Exception + mock_device.objects.get.side_effect = ValueError("invalid literal") + response = view.post(request, device_id=1) + + assert response.status_code == 200 + assert b"Existing device not found" in response.content + + def test_device_does_not_exist_returns_htmx_error(self): + """POST with an existing_device_id that refers to a deleted device returns HTMX error.""" + view = self._make_view() + request = _make_request(post={"existing_device_id": "999"}) + + with patch("dcim.models.Device") as mock_device: + mock_device.DoesNotExist = Exception + mock_device.objects.get.side_effect = Exception("not found") + response = view.post(request, device_id=1) + + assert response.status_code == 200 + assert b"Existing device not found" in response.content + + def test_no_oob_candidate_in_validation_returns_htmx_error(self): + """When validation has no oob_candidate, view returns an HTMX error.""" + view = self._make_view() + request = _make_request(post={"existing_device_id": "5"}) + + existing_device = MagicMock() + existing_device.pk = 5 + + with patch("dcim.models.Device") as mock_device: + mock_device.DoesNotExist = Exception + mock_device.objects.get.return_value = existing_device + view.get_validated_device_with_selections = MagicMock( + return_value=({"device_id": 99}, {"oob_candidate": None}, {}) + ) + response = view.post(request, device_id=99) + + assert response.status_code == 200 + assert b"No OOB candidate" in response.content + + def test_device_id_mismatch_returns_htmx_error(self): + """When oob_candidate device pk does not match existing_device_id, returns HTMX error.""" + view = self._make_view() + request = _make_request(post={"existing_device_id": "5"}) + + existing_device = MagicMock() + existing_device.pk = 5 + existing_device.custom_field_data = {"librenms_id": {"default": {"id": 10}}} + + oob_device = MagicMock() + oob_device.pk = 99 # Different PK + + with patch("dcim.models.Device") as mock_device: + mock_device.DoesNotExist = Exception + mock_device.objects.get.return_value = existing_device + view.get_validated_device_with_selections = MagicMock( + return_value=({"device_id": 50}, {"oob_candidate": {"device": oob_device, "type": "oob"}}, {}) + ) + response = view.post(request, device_id=50) + + assert response.status_code == 200 + assert b"mismatch" in response.content.lower() or b"Device ID mismatch" in response.content + + def test_legacy_librenms_id_returns_htmx_error(self): + """Device with legacy bare-int librenms_id is rejected with convert-first message.""" + view = self._make_view() + request = _make_request(post={"existing_device_id": "5"}) + + existing_device = MagicMock() + existing_device.pk = 5 + # Legacy bare-int librenms_id (not the expected dict structure) + existing_device.custom_field_data = {"librenms_id": 42} + + oob_candidate_device = MagicMock() + oob_candidate_device.pk = 5 + + with patch("dcim.models.Device") as mock_device: + mock_device.DoesNotExist = Exception + mock_device.objects.get.return_value = existing_device + view.get_validated_device_with_selections = MagicMock( + return_value=({"device_id": 77}, {"oob_candidate": {"device": oob_candidate_device, "type": "oob"}}, {}) + ) + response = view.post(request, device_id=77) + + assert response.status_code == 200 + assert b"legacy" in response.content.lower() + + def test_libre_device_not_found_returns_htmx_error(self): + """When get_validated_device_with_selections returns no libre_device, returns HTMX error.""" + view = self._make_view() + request = _make_request(post={"existing_device_id": "5"}) + + existing_device = MagicMock() + existing_device.pk = 5 + + with patch("dcim.models.Device") as mock_device: + mock_device.DoesNotExist = Exception + mock_device.objects.get.return_value = existing_device + view.get_validated_device_with_selections = MagicMock(return_value=(None, None, None)) + response = view.post(request, device_id=1) + + assert response.status_code == 200 + assert b"not found" in response.content.lower() diff --git a/netbox_librenms_plugin/tests/test_coverage_base_views.py b/netbox_librenms_plugin/tests/test_coverage_base_views.py index 5a691570a..6894c0846 100644 --- a/netbox_librenms_plugin/tests/test_coverage_base_views.py +++ b/netbox_librenms_plugin/tests/test_coverage_base_views.py @@ -138,6 +138,107 @@ def test_get_links_data_port_without_id_skipped(self): assert result is not None + def test_get_links_data_oob_uses_interface_name_field(self): + """OOB links resolve local port name via interface_name_field, not raw LLDP name.""" + + view = self._make_view() + + # Main device: one direct link + main_links = { + "links": [ + { + "local_port_id": 10, + "remote_hostname": "peer-a", + "remote_port": "Gi0/1", + "remote_port_id": 20, + "remote_device_id": 5, + } + ] + } + # OOB device: one link whose raw local_port is the ifName, not the stored ifDescr + oob_links = { + "links": [ + { + "local_port_id": 99, + "local_port": "eth0", + "remote_hostname": "peer-b", + "remote_port": "Gi0/2", + "remote_port_id": 21, + "remote_device_id": 6, + } + ] + } + + view._librenms_api.get_device_links.side_effect = [ + (True, main_links), + (True, oob_links), + ] + # OOB device ports: port_id=99 maps to ifDescr "Management0" (different from raw ifName "eth0") + view._librenms_api.get_ports.return_value = ( + True, + {"ports": [{"port_id": 99, "ifDescr": "Management0", "ifName": "eth0"}]}, + ) + + main_ports = {"ports": [{"port_id": 10, "ifDescr": "GigabitEthernet0/0"}]} + obj = _mock_obj() + + oob_mock = {"id": 7} + + with ( + patch.object(view, "get_ports_data", return_value=main_ports), + patch("netbox_librenms_plugin.views.base.cables_view.get_interface_name_field", return_value="ifDescr"), + patch("netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device", return_value=obj), + patch("netbox_librenms_plugin.views.base.cables_view.get_librenms_oob", return_value=oob_mock), + ): + result = view.get_links_data(obj) + + assert result is not None + assert len(result) == 2 + oob_entry = next(r for r in result if r["_source"] == "oob") + # Should use ifDescr "Management0", not raw LLDP value "eth0" + assert oob_entry["local_port"] == "Management0" + assert oob_entry["remote_device"] == "peer-b" + + def test_get_links_data_oob_falls_back_to_raw_name_on_port_fetch_failure(self): + """When OOB port fetch fails, falls back to raw local_port from LLDP data.""" + view = self._make_view() + + main_links = {"links": []} + oob_links = { + "links": [ + { + "local_port_id": 99, + "local_port": "eth0", + "remote_hostname": "peer-b", + "remote_port": "Gi0/2", + "remote_port_id": 21, + "remote_device_id": 6, + } + ] + } + + view._librenms_api.get_device_links.side_effect = [ + (True, main_links), + (True, oob_links), + ] + view._librenms_api.get_ports.return_value = (False, {}) + + main_ports = {"ports": []} + obj = _mock_obj() + oob_mock = {"id": 7} + + with ( + patch.object(view, "get_ports_data", return_value=main_ports), + patch("netbox_librenms_plugin.views.base.cables_view.get_interface_name_field", return_value="ifDescr"), + patch("netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device", return_value=obj), + patch("netbox_librenms_plugin.views.base.cables_view.get_librenms_oob", return_value=oob_mock), + ): + result = view.get_links_data(obj) + + assert result is not None + oob_entry = result[0] + assert oob_entry["local_port"] == "eth0" # Raw fallback + class TestBaseCableTableViewGetDeviceByIdOrName: """Tests for BaseCableTableView.get_device_by_id_or_name.""" diff --git a/netbox_librenms_plugin/tests/test_coverage_device_operations.py b/netbox_librenms_plugin/tests/test_coverage_device_operations.py index 22b5c86fe..900ce209a 100644 --- a/netbox_librenms_plugin/tests/test_coverage_device_operations.py +++ b/netbox_librenms_plugin/tests/test_coverage_device_operations.py @@ -1679,3 +1679,804 @@ def test_chassis_match_overrides_hardware_match(self): device_patch.stop() assert result["device_type"].get("device_type") is chassis_dt + + +class TestOOBDetection: + """Tests for OOB candidate detection in validate_device_for_import (Phase 2).""" + + def _make_api(self, server_key="default"): + api = MagicMock() + api.server_key = server_key + return api + + def _base_patches(self, mock_device_cls, mock_vm_cls=None): + """Return a list of common patches (start/stop must be called by caller). + + Does NOT patch find_by_librenms_id — each test adds it explicitly so that + when the same target is patched twice the stop order (reversed) is clear. + """ + if mock_vm_cls is None: + mock_vm_cls = MagicMock() + mock_vm_cls.objects.filter.return_value.first.return_value = None + return [ + patch("netbox_librenms_plugin.import_utils.device_operations.Site"), + patch("netbox_librenms_plugin.import_utils.device_operations.DeviceType"), + patch("netbox_librenms_plugin.import_utils.device_operations.DeviceRole"), + patch("netbox_librenms_plugin.import_utils.device_operations.cache"), + patch("ipam.models.IPAddress"), + patch("virtualization.models.VirtualMachine", new=mock_vm_cls), + patch( + "netbox_librenms_plugin.import_utils.device_operations.match_librenms_hardware_to_device_type", + return_value={"matched": False}, + ), + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_matching_site", + return_value={"found": False, "site": None, "match_type": None}, + ), + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_matching_platform", + return_value={"found": False, "platform": None, "match_type": None}, + ), + patch( + "netbox_librenms_plugin.import_utils.device_operations.get_virtual_chassis_data", + return_value={"is_stack": False, "member_count": 0, "members": []}, + ), + ] + + # ------------------------------------------------------------------ + # Case 1: Serial match + OOB regex → oob_candidate + # ------------------------------------------------------------------ + def test_serial_match_oob_type_sets_oob_candidate(self): + """Serial matches + device os=idrac → serial_action='oob_candidate', oob_candidate populated.""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 17, + "hostname": "idrac-server01", + "sysName": "idrac-server01", + "hardware": "iDRAC9", + "serial": "ABC123", + "os": "idrac", + "ip": "10.0.0.5", + "version": "5.10.50", + "location": "", + } + api = self._make_api() + + existing = MagicMock() + existing.name = "server01" + existing.custom_field_data = {"librenms_id": {"default": 42}} + existing.cf = {"librenms_id": {"default": 42}} + + mock_device_cls = MagicMock() + # hostname check → None, serial check → existing + mock_device_cls.objects.filter.return_value.first.side_effect = [None, existing] + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + patch("netbox_librenms_plugin.import_utils.device_operations.get_librenms_oob", return_value=None), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert result["serial_action"] == "oob_candidate" + assert result["oob_candidate"] is not None + assert result["oob_candidate"]["type"] == "idrac" + assert result["oob_candidate"]["ip"] == "10.0.0.5" + assert result["oob_candidate"]["version"] == "5.10.50" + assert result["can_import"] is False + + # ------------------------------------------------------------------ + # Case 2: Serial match + OOB regex + OOB already set → serial_action="link" + # ------------------------------------------------------------------ + def test_serial_match_oob_already_linked_falls_back_to_link(self): + """Serial matches + OOB type + existing OOB already set → serial_action='link'.""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 17, + "hostname": "idrac-server01", + "sysName": "idrac-server01", + "hardware": "iDRAC9", + "serial": "ABC123", + "os": "idrac", + "ip": "10.0.0.5", + "version": "5.10.50", + "location": "", + } + api = self._make_api() + + existing = MagicMock() + existing.name = "server01" + existing.custom_field_data = {"librenms_id": {"default": {"id": 42, "oob": {"id": 17, "type": "idrac"}}}} + existing.cf = {"librenms_id": {"default": {"id": 42, "oob": {"id": 17, "type": "idrac"}}}} + + mock_device_cls = MagicMock() + mock_device_cls.objects.filter.return_value.first.side_effect = [None, existing] + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None + + existing_oob = {"id": 17, "type": "idrac"} + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + patch("netbox_librenms_plugin.import_utils.device_operations.get_librenms_oob", return_value=existing_oob), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert result["serial_action"] == "link" + assert result["oob_candidate"] is None + + # ------------------------------------------------------------------ + # Case 1b: Same as Case 1 but device_id is a string — exercises coercion + # ------------------------------------------------------------------ + def test_serial_match_oob_type_string_device_id(self): + """device_id as string '17' is coerced correctly — serial_action='oob_candidate'.""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": "17", # string, not int + "hostname": "idrac-server01", + "sysName": "idrac-server01", + "hardware": "iDRAC9", + "serial": "ABC123", + "os": "idrac", + "ip": "10.0.0.5", + "version": "5.10.50", + "location": "", + } + api = self._make_api() + + existing = MagicMock() + existing.name = "server01" + existing.custom_field_data = {"librenms_id": {"default": "42"}} # stored id also a string + existing.cf = {"librenms_id": {"default": "42"}} + + mock_device_cls = MagicMock() + mock_device_cls.objects.filter.return_value.first.side_effect = [None, existing] + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + patch("netbox_librenms_plugin.import_utils.device_operations.get_librenms_oob", return_value=None), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert result["serial_action"] == "oob_candidate" + assert result["oob_candidate"] is not None + assert result["oob_candidate"]["type"] == "idrac" + + # ------------------------------------------------------------------ + # Case 2b: Same as Case 2 but device_id is a string — OOB already linked + # ------------------------------------------------------------------ + def test_serial_match_oob_already_linked_string_device_id(self): + """String device_id '17' matches stored OOB id 17 (int) — serial_action='link'.""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": "17", # string, not int + "hostname": "idrac-server01", + "sysName": "idrac-server01", + "hardware": "iDRAC9", + "serial": "ABC123", + "os": "idrac", + "ip": "10.0.0.5", + "version": "5.10.50", + "location": "", + } + api = self._make_api() + + existing = MagicMock() + existing.name = "server01" + existing.custom_field_data = {"librenms_id": {"default": {"id": 42, "oob": {"id": 17, "type": "idrac"}}}} + existing.cf = {"librenms_id": {"default": {"id": 42, "oob": {"id": 17, "type": "idrac"}}}} + + mock_device_cls = MagicMock() + mock_device_cls.objects.filter.return_value.first.side_effect = [None, existing] + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None + + existing_oob = {"id": 17, "type": "idrac"} + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + patch("netbox_librenms_plugin.import_utils.device_operations.get_librenms_oob", return_value=existing_oob), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + # "17" (str) == 17 (int) after coercion → already_linked_elsewhere=False → serial_action='link' + assert result["serial_action"] == "link" + assert result["oob_candidate"] is None + + def test_serial_match_non_oob_type_uses_standard_logic(self): + """Serial matches but os=linux → standard serial_action (link or hostname_differs).""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 42, + "hostname": "server01", + "sysName": "server01", + "hardware": "PowerEdge R640", + "serial": "ABC123", + "os": "linux", + "ip": "192.168.1.1", + "version": "", + "location": "", + } + api = self._make_api() + + existing = MagicMock() + existing.name = "server01" + + mock_device_cls = MagicMock() + # hostname check → None, serial check → existing + mock_device_cls.objects.filter.return_value.first.side_effect = [None, existing] + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert result["serial_action"] in ("link", "hostname_differs") + assert result["oob_candidate"] is None + + # ------------------------------------------------------------------ + # Case 4: result dict always has oob_candidate key (even when not set) + # ------------------------------------------------------------------ + def test_result_always_contains_oob_candidate_key(self): + """result dict always includes oob_candidate key regardless of detection path.""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 1, + "hostname": "sw01", + "sysName": "sw01", + "hardware": "SomeSwitch", + "serial": "", + "os": "ios", + "ip": "", + "version": "", + "location": "", + } + api = self._make_api() + + mock_device_cls = MagicMock() + mock_device_cls.objects.filter.return_value.first.return_value = None + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert "oob_candidate" in result + assert result["oob_candidate"] is None + + # ------------------------------------------------------------------ + # Case 4b: Inverse-OOB — existing NetBox device named like an OOB, + # already linked to a different LibreNMS id, incoming is the host. + # ------------------------------------------------------------------ + def test_serial_match_inverse_oob_sets_promote_to_host(self): + """Existing device named 'idrac-*' linked to libre #99; incoming host (os=linux) shares serial → promote_to_host.""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 42, + "hostname": "eve-ng-02", + "sysName": "eve-ng-02", + "hardware": "Dell PowerEdge R770", + "serial": "ABC123", + "os": "linux", + "ip": "10.0.0.10", + "version": "", + "location": "", + } + api = self._make_api() + + existing = MagicMock() + existing.name = "idrac-jhw6nc4" + existing.cf = {"librenms_id": {"default": {"id": 99}}} + + mock_device_cls = MagicMock() + mock_device_cls.objects.filter.return_value.first.side_effect = [None, existing] + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert result["serial_action"] == "promote_to_host" + pt = result["promote_to_host"] + assert pt["existing_libre_id"] == 99 + assert pt["existing_oob_type"] == "idrac" + # existing_device is the NetBox device object — verify it is present and is + # the same object the mock supplied (MagicMock with name="idrac-jhw6nc4"). + assert pt["existing_device"] is existing + assert result["existing_librenms_link"] == { + "host_id": 99, + "oob_id": None, + "oob_type": None, + } + assert result["can_import"] is False + + def test_serial_match_inverse_oob_skipped_when_existing_already_has_oob(self): + """If existing device already has an OOB linked, do NOT offer promote_to_host.""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 42, + "hostname": "eve-ng-02", + "sysName": "eve-ng-02", + "hardware": "Dell PowerEdge R770", + "serial": "ABC123", + "os": "linux", + "ip": "10.0.0.10", + "version": "", + "location": "", + } + api = self._make_api() + + existing = MagicMock() + existing.name = "idrac-jhw6nc4" + # Both host id and OOB already set — the merge has already happened. + existing.cf = {"librenms_id": {"default": {"id": 100, "oob": {"id": 99, "type": "idrac"}}}} + + mock_device_cls = MagicMock() + mock_device_cls.objects.filter.return_value.first.side_effect = [None, existing] + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert result["serial_action"] != "promote_to_host" + assert result.get("promote_to_host") is None + assert result["existing_librenms_link"]["oob_id"] == 99 + + def test_serial_role_choice_available_offers_both_options_when_feasible(self): + """When existing has a different LibreNMS host id linked, no OOB, and no name match, + both oob_candidate and promote_to_host are populated and serial_role_choice_available + signals the UI to render the host/OOB toggle.""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 42, + "hostname": "eve-ng-02", + "sysName": "eve-ng-02", + "hardware": "Dell PowerEdge R770", + "serial": "ABC123", + "os": "linux", + "ip": "10.0.0.10", + "version": "", + "location": "", + } + api = self._make_api() + + existing = MagicMock() + existing.name = "idrac-jhw6nc4" # OOB pattern in name -> heuristic picks promote + existing.cf = {"librenms_id": {"default": {"id": 25}}} + + mock_device_cls = MagicMock() + mock_device_cls.objects.filter.return_value.first.side_effect = [None, existing] + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + # Heuristic chose promote_to_host (existing name suggests OOB, host link demote-able). + assert result["serial_action"] == "promote_to_host" + # Toggle is available so the user can flip to "Add as OOB" instead. + assert result["serial_role_choice_available"] is True + assert result["oob_candidate"] is not None + assert result["oob_candidate"]["device"] is existing + assert result["promote_to_host"] is not None + assert result["promote_to_host"]["existing_libre_id"] == 25 + + def test_serial_role_choice_not_available_when_names_match_and_no_link(self): + """Exact name match with no existing LibreNMS link -> simple link case, no toggle.""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 42, + "hostname": "server01", + "sysName": "server01", + "hardware": "PowerEdge R640", + "serial": "XYZ789", + "os": "linux", + "ip": "192.168.1.1", + "version": "", + "location": "", + } + api = self._make_api() + + existing = MagicMock() + existing.name = "server01" + existing.cf = {} # not linked + + mock_device_cls = MagicMock() + mock_device_cls.objects.filter.return_value.first.side_effect = [None, existing] + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert result["serial_action"] == "link" + assert result.get("serial_role_choice_available") is False + assert result["oob_candidate"] is None + assert result.get("promote_to_host") is None + + def test_serial_match_inverse_oob_requires_oob_pattern_in_name(self): + """Existing device without OOB pattern in its name but with a different LibreNMS link + is now treated as an ambiguous host/OOB chassis pair: both options are populated and a + UI role-toggle is offered, defaulting to oob_candidate (least-destructive).""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 42, + "hostname": "eve-ng-02", + "sysName": "eve-ng-02", + "hardware": "Dell PowerEdge R770", + "serial": "ABC123", + "os": "linux", + "ip": "10.0.0.10", + "version": "", + "location": "", + } + api = self._make_api() + + existing = MagicMock() + existing.name = "old-server-name" # no OOB pattern + existing.cf = {"librenms_id": {"default": {"id": 99}}} + + mock_device_cls = MagicMock() + mock_device_cls.objects.filter.return_value.first.side_effect = [None, existing] + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert result["serial_action"] == "oob_candidate" + assert result["serial_role_choice_available"] is True + assert result["oob_candidate"] is not None + assert result["promote_to_host"] is not None + assert result["promote_to_host"]["existing_libre_id"] == 99 + # existing link state is still surfaced + assert result["existing_librenms_link"]["host_id"] == 99 + + # ------------------------------------------------------------------ + # Stage 2: two-NetBox-device merge detection + # ------------------------------------------------------------------ + def test_merge_candidates_detected_when_hostname_and_serial_match_different_devices(self): + """Hostname matches device A, serial matches different device B (A has link) → merge_netbox_devices.""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 42, + "hostname": "eve-ng-02", + "sysName": "eve-ng-02", + "hardware": "Dell PowerEdge R770", + "serial": "ABC123", + "os": "linux", + "ip": "10.0.0.10", + "version": "", + "location": "", + } + api = self._make_api() + + host_named = MagicMock() + host_named.name = "eve-ng-02" + host_named.pk = 100 + host_named.serial = "ABC123" # same — skips serial-conflict path inside hostname branch + host_named.cf = {"librenms_id": {"default": {"id": 42}}} + host_named.custom_field_data = {"librenms_id": {"default": {"id": 42}}} + + oob_named = MagicMock() + oob_named.name = "idrac-jhw6nc4" + oob_named.pk = 200 + oob_named.serial = "ABC123" + oob_named.cf = {"librenms_id": {"default": {"id": 99}}} + oob_named.custom_field_data = {"librenms_id": {"default": {"id": 99}}} + + mock_device_cls = MagicMock() + # filter().first() — hostname lookup → host_named + mock_device_cls.objects.filter.return_value.first.return_value = host_named + # filter().exclude().first() — used by my merge-detect branch to find the serial-twin + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = oob_named + # isinstance(existing_device, Device) check — make it accept MagicMock objects. + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert result["serial_action"] == "merge_netbox_devices" + assert result["merge_candidates"] is not None + assert result["merge_candidates"]["host_named"]["pk"] == 100 + assert result["merge_candidates"]["host_named"]["name"] == "eve-ng-02" + assert result["merge_candidates"]["oob_named"]["pk"] == 200 + assert result["merge_candidates"]["oob_named"]["name"] == "idrac-jhw6nc4" + assert result["merge_candidates"]["host_named"]["librenms_link"]["host_id"] == 42 + assert result["merge_candidates"]["oob_named"]["librenms_link"]["host_id"] == 99 + assert result["can_import"] is False + + def test_merge_candidates_skipped_when_neither_device_has_librenms_link(self): + """Two different devices share serial but neither has librenms link → conservative skip.""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 42, + "hostname": "eve-ng-02", + "sysName": "eve-ng-02", + "hardware": "Dell PowerEdge R770", + "serial": "ABC123", + "os": "linux", + "ip": "10.0.0.10", + "version": "", + "location": "", + } + api = self._make_api() + + host_named = MagicMock() + host_named.name = "eve-ng-02" + host_named.pk = 100 + host_named.serial = "ABC123" + host_named.cf = {} # no librenms link + host_named.custom_field_data = {} + + oob_named = MagicMock() + oob_named.name = "idrac-jhw6nc4" + oob_named.pk = 200 + oob_named.serial = "ABC123" + oob_named.cf = {} + oob_named.custom_field_data = {} + + mock_device_cls = MagicMock() + mock_device_cls.objects.filter.return_value.first.return_value = host_named + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = oob_named + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert result["serial_action"] != "merge_netbox_devices" + assert result["merge_candidates"] is None + + def test_merge_candidates_skipped_when_only_one_device(self): + """Hostname matches, no other device by serial → no merge candidates.""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 42, + "hostname": "eve-ng-02", + "sysName": "eve-ng-02", + "hardware": "Dell PowerEdge R770", + "serial": "ABC123", + "os": "linux", + "ip": "10.0.0.10", + "version": "", + "location": "", + } + api = self._make_api() + + host_named = MagicMock() + host_named.name = "eve-ng-02" + host_named.pk = 100 + host_named.serial = "ABC123" + host_named.cf = {"librenms_id": {"default": {"id": 42}}} + host_named.custom_field_data = {"librenms_id": {"default": {"id": 42}}} + + mock_device_cls = MagicMock() + mock_device_cls.objects.filter.return_value.first.return_value = host_named + # No serial twin + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None + + patches = self._base_patches(mock_device_cls) + [ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + return_value=None, + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert result["serial_action"] != "merge_netbox_devices" + assert result["merge_candidates"] is None + + # ------------------------------------------------------------------ + # Case 5: Re-import via OOB id → existing_match_type = "librenms_oob" + # ------------------------------------------------------------------ + def test_reimport_via_oob_id_sets_match_type_librenms_oob(self): + """find_by_librenms_id returns device when OOB id matches → existing_match_type='librenms_oob'.""" + from netbox_librenms_plugin.import_utils.device_operations import validate_device_for_import + + libre_device = { + "device_id": 17, + "hostname": "idrac-server01", + "sysName": "idrac-server01", + "hardware": "iDRAC9", + "serial": "ABC123", + "os": "idrac", + "ip": "10.0.0.5", + "version": "5.10.50", + "location": "", + } + api = self._make_api() + + existing = MagicMock() + existing.name = "server01" + + # Simulate: find_by_librenms_id matched on OOB id (id=17 is oob.id) + existing_oob = {"id": 17, "type": "idrac"} + + mock_device_cls = MagicMock() + mock_device_cls.objects.filter.return_value.first.return_value = None + mock_device_cls.objects.filter.return_value.exclude.return_value.first.return_value = None + + # find_by_librenms_id: first call for VM returns None, second call for Device returns existing + find_by_id_mock = MagicMock(side_effect=[None, existing]) + + patches = self._base_patches(mock_device_cls) + [ + patch("netbox_librenms_plugin.import_utils.device_operations.Device", new=mock_device_cls), + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + new=find_by_id_mock, + ), + patch( + "netbox_librenms_plugin.import_utils.device_operations.get_librenms_oob", + return_value=existing_oob, + ), + ] + for p in patches: + p.start() + try: + result = validate_device_for_import(libre_device, api=api) + finally: + for p in reversed(patches): + p.stop() + + assert result["existing_match_type"] == "librenms_oob" + assert result["existing_device"] is existing diff --git a/netbox_librenms_plugin/tests/test_coverage_list.py b/netbox_librenms_plugin/tests/test_coverage_list.py index 30d8615c5..1ffb57c92 100644 --- a/netbox_librenms_plugin/tests/test_coverage_list.py +++ b/netbox_librenms_plugin/tests/test_coverage_list.py @@ -504,7 +504,7 @@ def test_get_job_id_loads_results(self): with patch.object(view, "get_server_info", return_value={}): view.get(request) - mock_load.assert_called_once_with(42) + mock_load.assert_called_once_with(42, request=request) def test_get_job_id_preserves_vc_flag_when_query_flag_missing(self): """job_id pages keep vc_detection_enabled from loaded job results.""" @@ -515,7 +515,7 @@ def test_get_job_id_preserves_vc_flag_when_query_flag_missing(self): mock_api = MagicMock() mock_api.server_key = "default" - def _load_job_side_effect(_job_id): + def _load_job_side_effect(_job_id, request=None): view._vc_detection_enabled = True return [{"device_id": 1, "hostname": "router1"}] @@ -543,7 +543,7 @@ def _load_job_side_effect(_job_id): with patch.object(view, "get_server_info", return_value={}): view.get(request) - mock_load.assert_called_once_with(42) + mock_load.assert_called_once_with(42, request=request) context = mock_render.call_args[0][2] assert context["vc_detection_enabled"] is True diff --git a/netbox_librenms_plugin/tests/test_import_utils.py b/netbox_librenms_plugin/tests/test_import_utils.py index f7d8620c0..d581f4ef5 100644 --- a/netbox_librenms_plugin/tests/test_import_utils.py +++ b/netbox_librenms_plugin/tests/test_import_utils.py @@ -1723,11 +1723,25 @@ def device_filter(*args, **kwargs): assert result["existing_match_type"] == "serial" assert "not linked to LibreNMS" in result["warnings"][0] - def test_serial_match_diff_hostname_offers_hostname_differs(self): - """Serial matches but hostname differs offers hostname_differs action.""" + def test_serial_match_diff_hostname_offers_role_choice(self): + """Serial matches but hostname differs offers a host/OOB role choice toggle. + + Previously this returned `hostname_differs`. With the role-toggle work + (see device_validation_details.html + device_operations.py refactor), + when an existing NetBox device matches by serial but the names differ + AND neither host nor OOB role is conclusively chosen by the heuristic, + the `oob_candidate` data block is populated and the default action is + `oob_candidate` (least destructive). + + `promote_to_host` is only populated when the existing device already + carries an OOB link (so there is a host_id to inherit); without an + existing LibreNMS link, only `oob_candidate` is surfaced. + """ existing = MagicMock() existing.name = "old-hostname" existing.serial = "ABC123" + existing.pk = 7 + existing.custom_field_data = {} self.mock_vm.objects.filter.return_value.first.return_value = None @@ -1746,9 +1760,10 @@ def device_filter(*args, **kwargs): device_data = {"device_id": 1, "hostname": "new-hostname", "serial": "ABC123"} result = validate_device_for_import(device_data, include_vc_detection=False) - assert result["serial_action"] == "hostname_differs" + assert result["serial_action"] == "oob_candidate" assert result["existing_match_type"] == "serial" - assert "hostname differs" in result["warnings"][0] + assert result.get("oob_candidate") is not None + assert result.get("serial_role_choice_available") is False def test_hostname_match_diff_serial_offers_update(self): """Hostname matches but serial differs offers update_serial action.""" diff --git a/netbox_librenms_plugin/tests/test_import_validation_helpers.py b/netbox_librenms_plugin/tests/test_import_validation_helpers.py index 4e58e0996..1309e29ea 100644 --- a/netbox_librenms_plugin/tests/test_import_validation_helpers.py +++ b/netbox_librenms_plugin/tests/test_import_validation_helpers.py @@ -366,3 +366,171 @@ def test_recalculate_can_import_vm_missing_cluster(self): assert validation["can_import"] is True # No issues assert validation["is_ready"] is False # But not ready without cluster + + +# ============================================================================= +# TestApplyOobDetectionResult - 6 tests +# ============================================================================= + + +class TestApplyOobDetectionResult: + """Tests for apply_oob_detection_result helper.""" + + def _base_result(self): + return { + "serial_action": None, + "oob_candidate": None, + "promote_to_host": None, + "serial_role_choice_available": False, + "warnings": [], + } + + def test_sets_serial_action(self): + from netbox_librenms_plugin.import_validation_helpers import apply_oob_detection_result + + result = self._base_result() + apply_oob_detection_result( + result, + serial_action="oob_candidate", + oob_candidate=None, + promote_to_host=None, + serial_role_choice_available=False, + ) + assert result["serial_action"] == "oob_candidate" + + def test_sets_oob_candidate_when_provided(self): + from netbox_librenms_plugin.import_validation_helpers import apply_oob_detection_result + + result = self._base_result() + candidate = {"device": object(), "type": "idrac", "version": None, "ip": "10.0.0.1"} + apply_oob_detection_result( + result, + serial_action="oob_candidate", + oob_candidate=candidate, + promote_to_host=None, + serial_role_choice_available=False, + ) + assert result["oob_candidate"] is candidate + + def test_clears_oob_candidate_when_none(self): + from netbox_librenms_plugin.import_validation_helpers import apply_oob_detection_result + + existing = {"device": object(), "type": "ilo", "version": None, "ip": None} + result = self._base_result() + result["oob_candidate"] = existing + apply_oob_detection_result( + result, + serial_action="link", + oob_candidate=None, + promote_to_host=None, + serial_role_choice_available=False, + ) + # oob_candidate=None means "no candidate" -- field must be cleared to None + # so stale values from a previous call do not persist. + assert result["oob_candidate"] is None + + def test_sets_promote_to_host_when_provided(self): + from netbox_librenms_plugin.import_validation_helpers import apply_oob_detection_result + + result = self._base_result() + promo = {"existing_libre_id": 7, "existing_oob_type": "idrac", "existing_device": object()} + apply_oob_detection_result( + result, + serial_action="promote_to_host", + oob_candidate=None, + promote_to_host=promo, + serial_role_choice_available=False, + ) + assert result["promote_to_host"] is promo + + def test_serial_role_choice_available_flag(self): + from netbox_librenms_plugin.import_validation_helpers import apply_oob_detection_result + + result = self._base_result() + apply_oob_detection_result( + result, + serial_action="oob_candidate", + oob_candidate={"device": object(), "type": "oob", "version": None, "ip": None}, + promote_to_host={"existing_libre_id": 3, "existing_oob_type": "oob", "existing_device": object()}, + serial_role_choice_available=True, + ) + assert result["serial_role_choice_available"] is True + + def test_appends_warnings(self): + from netbox_librenms_plugin.import_validation_helpers import apply_oob_detection_result + + result = self._base_result() + result["warnings"] = ["pre-existing"] + apply_oob_detection_result( + result, + serial_action="link", + oob_candidate=None, + promote_to_host=None, + serial_role_choice_available=False, + warnings=["new warning 1", "new warning 2"], + ) + assert result["warnings"] == ["pre-existing", "new warning 1", "new warning 2"] + + +# ============================================================================= +# TestApplyMergeCandidates - 4 tests +# ============================================================================= + + +class TestApplyMergeCandidates: + """Tests for apply_merge_candidates helper.""" + + def _base_result(self): + return { + "serial_action": None, + "merge_candidates": None, + "can_import": True, + "warnings": [], + } + + def test_sets_serial_action_to_merge(self): + from netbox_librenms_plugin.import_validation_helpers import apply_merge_candidates + + result = self._base_result() + apply_merge_candidates( + result, + host_named={"pk": 1, "name": "router-01", "librenms_link": {"host_id": 10}}, + oob_named={"pk": 2, "name": "router-01-idrac", "librenms_link": None}, + warning="Two devices found", + ) + assert result["serial_action"] == "merge_netbox_devices" + + def test_sets_merge_candidates_dict(self): + from netbox_librenms_plugin.import_validation_helpers import apply_merge_candidates + + result = self._base_result() + host = {"pk": 1, "name": "router-01", "librenms_link": {"host_id": 10}} + oob = {"pk": 2, "name": "router-01-idrac", "librenms_link": None} + apply_merge_candidates(result, host_named=host, oob_named=oob, warning="w") + assert result["merge_candidates"] == {"host_named": host, "oob_named": oob} + + def test_sets_can_import_false(self): + from netbox_librenms_plugin.import_validation_helpers import apply_merge_candidates + + result = self._base_result() + result["can_import"] = True + apply_merge_candidates( + result, + host_named={"pk": 1, "name": "h", "librenms_link": None}, + oob_named={"pk": 2, "name": "o", "librenms_link": None}, + warning="merge needed", + ) + assert result["can_import"] is False + + def test_appends_warning(self): + from netbox_librenms_plugin.import_validation_helpers import apply_merge_candidates + + result = self._base_result() + result["warnings"] = ["existing"] + apply_merge_candidates( + result, + host_named={"pk": 1, "name": "h", "librenms_link": None}, + oob_named={"pk": 2, "name": "o", "librenms_link": None}, + warning="merge warning", + ) + assert result["warnings"] == ["existing", "merge warning"] diff --git a/netbox_librenms_plugin/tests/test_librenms_id.py b/netbox_librenms_plugin/tests/test_librenms_id.py index 5fa555985..2e945d899 100644 --- a/netbox_librenms_plugin/tests/test_librenms_id.py +++ b/netbox_librenms_plugin/tests/test_librenms_id.py @@ -131,6 +131,10 @@ def test_queries_server_key_and_legacy_integer(self): assert set(q_arg.children) == { ("custom_field_data__librenms_id__default", 42), ("custom_field_data__librenms_id__default", "42"), + ("custom_field_data__librenms_id__default__id", 42), + ("custom_field_data__librenms_id__default__id", "42"), + ("custom_field_data__librenms_id__default__oob__id", 42), + ("custom_field_data__librenms_id__default__oob__id", "42"), ("custom_field_data__librenms_id", 42), ("custom_field_data__librenms_id", "42"), } @@ -168,6 +172,10 @@ def test_returns_none_when_not_found(self): assert set(q_arg.children) == { ("custom_field_data__librenms_id__production", 999), ("custom_field_data__librenms_id__production", "999"), + ("custom_field_data__librenms_id__production__id", 999), + ("custom_field_data__librenms_id__production__id", "999"), + ("custom_field_data__librenms_id__production__oob__id", 999), + ("custom_field_data__librenms_id__production__oob__id", "999"), ("custom_field_data__librenms_id", 999), ("custom_field_data__librenms_id", "999"), } @@ -200,6 +208,10 @@ def test_default_server_key_is_default(self): assert set(q_arg.children) == { ("custom_field_data__librenms_id__default", 42), ("custom_field_data__librenms_id__default", "42"), + ("custom_field_data__librenms_id__default__id", 42), + ("custom_field_data__librenms_id__default__id", "42"), + ("custom_field_data__librenms_id__default__oob__id", 42), + ("custom_field_data__librenms_id__default__oob__id", "42"), ("custom_field_data__librenms_id", 42), ("custom_field_data__librenms_id", "42"), } @@ -372,3 +384,420 @@ def test_unexpected_cf_type_reset_to_empty(self): obj.custom_field_data = {"librenms_id": "unexpected-string"} set_librenms_device_id(obj, 5, server_key="primary") assert obj.custom_field_data["librenms_id"] == {"primary": 5} + + +class TestOOBHelpers: + """Tests for get_librenms_oob, set_librenms_oob, clear_librenms_oob, + and the dict-with-id changes to get/set_librenms_device_id and find_by_librenms_id. + """ + + # ── get_librenms_device_id: dict-with-id form ───────────────────────────── + + def test_get_id_from_dict_with_id_form(self): + """get_librenms_device_id extracts 'id' from {"server": {"id": N}} form.""" + from netbox_librenms_plugin.utils import get_librenms_device_id + + obj = MagicMock() + obj.cf = {"librenms_id": {"primary": {"id": 42}}} + assert get_librenms_device_id(obj, "primary") == 42 + + def test_get_id_when_oob_also_present(self): + """get_librenms_device_id returns the main id, ignoring the oob sub-object.""" + from netbox_librenms_plugin.utils import get_librenms_device_id + + obj = MagicMock() + obj.cf = {"librenms_id": {"primary": {"id": 42, "oob": {"id": 17, "type": "drac"}}}} + assert get_librenms_device_id(obj, "primary") == 42 + + def test_get_returns_none_for_dict_without_id_key(self): + """dict entry without 'id' key returns None.""" + from netbox_librenms_plugin.utils import get_librenms_device_id + + obj = MagicMock() + obj.cf = {"librenms_id": {"primary": {"oob": {"id": 17}}}} + assert get_librenms_device_id(obj, "primary") is None + + def test_get_normalises_string_id_inside_dict_with_id_form(self): + """String 'id' inside dict-with-id form is coerced to int.""" + from netbox_librenms_plugin.utils import get_librenms_device_id + + obj = MagicMock() + obj.cf = {"librenms_id": {"primary": {"id": "42"}}} + obj.custom_field_data = {"librenms_id": {"primary": {"id": "42"}}} + result = get_librenms_device_id(obj, "primary", auto_save=False) + assert result == 42 + + # ── set_librenms_device_id: oob preservation ───────────────────────────── + + def test_set_preserves_oob_when_entry_has_oob(self): + """Updating main id preserves existing oob sub-object.""" + from netbox_librenms_plugin.utils import set_librenms_device_id + + obj = MagicMock() + obj.custom_field_data = { + "librenms_id": {"primary": {"id": 42, "oob": {"id": 17, "type": "drac", "ip": "10.0.0.5"}}} + } + set_librenms_device_id(obj, 99, server_key="primary") + assert obj.custom_field_data["librenms_id"] == { + "primary": {"id": 99, "oob": {"id": 17, "type": "drac", "ip": "10.0.0.5"}} + } + + def test_set_bare_int_when_no_oob_present(self): + """When no oob in existing entry, set_librenms_device_id stores bare int (no regression).""" + from netbox_librenms_plugin.utils import set_librenms_device_id + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"primary": 42}} + set_librenms_device_id(obj, 99, server_key="primary") + assert obj.custom_field_data["librenms_id"] == {"primary": 99} + + # ── find_by_librenms_id: dict-with-id and oob id lookups ───────────────── + + def test_find_by_matches_main_id_in_dict_with_id_form(self): + """find_by_librenms_id Q includes __id sub-key lookup.""" + from netbox_librenms_plugin.utils import find_by_librenms_id + + mock_model = MagicMock() + mock_qs = MagicMock() + mock_model.objects.filter.return_value = mock_qs + mock_qs.first.return_value = None + + find_by_librenms_id(mock_model, 42, "primary") + + call_args = mock_model.objects.filter.call_args + q_arg = call_args[0][0] + assert ("custom_field_data__librenms_id__primary__id", 42) in q_arg.children + assert ("custom_field_data__librenms_id__primary__id", "42") in q_arg.children + + def test_find_by_matches_oob_id(self): + """find_by_librenms_id Q includes __oob__id sub-key lookup.""" + from netbox_librenms_plugin.utils import find_by_librenms_id + + mock_model = MagicMock() + mock_qs = MagicMock() + mock_model.objects.filter.return_value = mock_qs + mock_qs.first.return_value = None + + find_by_librenms_id(mock_model, 17, "primary") + + call_args = mock_model.objects.filter.call_args + q_arg = call_args[0][0] + assert ("custom_field_data__librenms_id__primary__oob__id", 17) in q_arg.children + assert ("custom_field_data__librenms_id__primary__oob__id", "17") in q_arg.children + + def test_find_by_does_not_return_unrelated_id(self): + """find_by_librenms_id returns None when no model matches.""" + from netbox_librenms_plugin.utils import find_by_librenms_id + + mock_model = MagicMock() + mock_qs = MagicMock() + mock_model.objects.filter.return_value = mock_qs + mock_qs.first.return_value = None + + result = find_by_librenms_id(mock_model, 999, "primary") + assert result is None + + # ── get_librenms_oob ────────────────────────────────────────────────────── + + def test_get_oob_returns_none_for_legacy_bare_int(self): + from netbox_librenms_plugin.utils import get_librenms_oob + + obj = MagicMock() + obj.cf = {"librenms_id": 42} + assert get_librenms_oob(obj, "primary") is None + + def test_get_oob_returns_none_for_bare_int_entry(self): + """When server-key entry is a bare int (no oob), returns None.""" + from netbox_librenms_plugin.utils import get_librenms_oob + + obj = MagicMock() + obj.cf = {"librenms_id": {"primary": 42}} + assert get_librenms_oob(obj, "primary") is None + + def test_get_oob_returns_oob_dict_when_present(self): + from netbox_librenms_plugin.utils import get_librenms_oob + + oob_data = {"id": 17, "type": "drac", "version": "5.10", "ip": "10.0.0.5"} + obj = MagicMock() + obj.cf = {"librenms_id": {"primary": {"id": 42, "oob": oob_data}}} + result = get_librenms_oob(obj, "primary") + assert result == oob_data + + # ── set_librenms_oob ────────────────────────────────────────────────────── + + def test_set_oob_round_trip(self): + """set_librenms_oob followed by get_librenms_oob returns equivalent values.""" + from netbox_librenms_plugin.utils import get_librenms_oob, set_librenms_oob + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"primary": 42}} + obj.cf = obj.custom_field_data + + set_librenms_oob(obj, 17, "primary", oob_type="drac", version="5.10", ip="10.0.0.5") + result = get_librenms_oob(obj, "primary") + + assert result == {"id": 17, "type": "drac", "version": "5.10", "ip": "10.0.0.5"} + + def test_set_oob_promotes_bare_int_entry(self): + """set_librenms_oob promotes a bare-int entry to dict form, preserving the main id.""" + from netbox_librenms_plugin.utils import get_librenms_device_id, set_librenms_oob + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"primary": 42}} + obj.cf = obj.custom_field_data + + set_librenms_oob(obj, 17, "primary", oob_type="idrac") + assert get_librenms_device_id(obj, "primary") == 42 + + def test_set_oob_rejects_unknown_type(self): + """set_librenms_oob raises ValueError for a type that doesn't match OOB_TYPE_PATTERN.""" + from netbox_librenms_plugin.utils import set_librenms_oob + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"primary": 42}} + + import pytest + + with pytest.raises(ValueError, match="does not match any known OOB type"): + set_librenms_oob(obj, 17, "primary", oob_type="ubuntu") + + def test_set_oob_accepts_generic_oob_sentinel(self): + """set_librenms_oob must accept "oob" as a generic fallback type. + + The detection layer in device_operations.py uses "oob" as a sentinel when + neither the LibreNMS OS/hardware fields nor the device names contain any + specific OOB keyword (idrac/ilo/ipmi/bmc/drac). This is the common case for + devices like switch consoles or PDUs. Rejecting "oob" caused HTTP 400 on + AddAsOOBView / PromoteToHostView for every such device. + """ + from netbox_librenms_plugin.utils import get_librenms_oob, set_librenms_oob + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"default": 99}} + obj.cf = obj.custom_field_data + + set_librenms_oob(obj, 55, "default", oob_type="oob") + result = get_librenms_oob(obj, "default") + + assert result is not None + assert result["id"] == 55 + assert result["type"] == "oob" + + def test_set_oob_generic_sentinel_case_insensitive(self): + """The "oob" sentinel is accepted case-insensitively (OOB, Oob, etc.).""" + from netbox_librenms_plugin.utils import set_librenms_oob + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"default": 99}} + + # Should not raise + set_librenms_oob(obj, 55, "default", oob_type="OOB") + assert obj.custom_field_data["librenms_id"]["default"]["oob"]["type"] == "oob" + + def test_set_oob_does_not_call_save(self): + """set_librenms_oob must NOT call obj.save() — caller is responsible.""" + from netbox_librenms_plugin.utils import set_librenms_oob + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"primary": 42}} + + set_librenms_oob(obj, 17, "primary", oob_type="ilo") + obj.save.assert_not_called() + + # ── clear_librenms_oob ──────────────────────────────────────────────────── + + def test_clear_oob_removes_oob_sub_key(self): + from netbox_librenms_plugin.utils import clear_librenms_oob, get_librenms_oob + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"primary": {"id": 42, "oob": {"id": 17, "type": "drac"}}}} + obj.cf = obj.custom_field_data + + clear_librenms_oob(obj, "primary") + assert get_librenms_oob(obj, "primary") is None + # Main id should still be accessible via dict-with-id form + assert obj.custom_field_data["librenms_id"]["primary"] == {"id": 42} + + def test_clear_oob_is_noop_when_no_oob(self): + """clear_librenms_oob is a no-op when oob key is not present.""" + from netbox_librenms_plugin.utils import clear_librenms_oob + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"primary": {"id": 42}}} + + clear_librenms_oob(obj, "primary") + assert obj.custom_field_data["librenms_id"] == {"primary": {"id": 42}} + + def test_clear_oob_does_not_call_save(self): + """clear_librenms_oob must NOT call obj.save() — caller is responsible.""" + from netbox_librenms_plugin.utils import clear_librenms_oob + + obj = MagicMock() + obj.custom_field_data = {"librenms_id": {"primary": {"id": 42, "oob": {"id": 17, "type": "bmc"}}}} + + clear_librenms_oob(obj, "primary") + obj.save.assert_not_called() + + +class TestMergeLibreNMSLinks: + """Tests for merge_librenms_links() — winner-wins conflict policy.""" + + def _make_dev(self, name, librenms_id_dict): + d = MagicMock() + d.name = name + d.custom_field_data = {"librenms_id": librenms_id_dict} if librenms_id_dict is not None else {} + return d + + def test_winner_inherits_id_when_winner_has_no_id(self): + from netbox_librenms_plugin.utils import merge_librenms_links + + winner = self._make_dev("eve-ng-02", {"default": {}}) + donor = self._make_dev("idrac-jhw6nc4", {"default": {"id": 99}}) + summary = merge_librenms_links(winner, donor, "default") + + assert winner.custom_field_data["librenms_id"]["default"]["id"] == 99 + assert summary["host_id_from_donor"] == 99 + assert summary["donor_id_demoted_to_oob"] is None + + def test_winner_inherits_string_id_coerced_to_int(self): + """inherit-id branch must coerce donor_id to int, matching the demote branch.""" + from netbox_librenms_plugin.utils import merge_librenms_links + + winner = self._make_dev("eve-ng-02", {"default": {}}) + # Simulate a custom field value that arrived as a JSON string (e.g. "99"). + donor = self._make_dev("router-spare", {"default": {"id": "99"}}) + summary = merge_librenms_links(winner, donor, "default") + + stored = winner.custom_field_data["librenms_id"]["default"]["id"] + assert stored == 99 + assert isinstance(stored, int) + assert summary["host_id_from_donor"] == 99 + assert isinstance(summary["host_id_from_donor"], int) + + def test_donor_id_demoted_to_oob_when_winner_has_id_and_donor_name_matches_oob_pattern(self): + from netbox_librenms_plugin.utils import merge_librenms_links + + winner = self._make_dev("eve-ng-02", {"default": {"id": 42}}) + donor = self._make_dev("idrac-jhw6nc4", {"default": {"id": 99}}) + summary = merge_librenms_links(winner, donor, "default") + + assert winner.custom_field_data["librenms_id"]["default"]["id"] == 42 + assert winner.custom_field_data["librenms_id"]["default"]["oob"]["id"] == 99 + assert winner.custom_field_data["librenms_id"]["default"]["oob"]["type"] == "idrac" + assert summary["donor_id_demoted_to_oob"] == {"id": 99, "type": "idrac"} + + def test_donor_id_demoted_to_oob_generic_when_no_pattern_in_name(self): + """Donor id is always demoted; type falls back to 'oob' when no keyword in name.""" + from netbox_librenms_plugin.utils import merge_librenms_links + + winner = self._make_dev("eve-ng-02", {"default": {"id": 42}}) + donor = self._make_dev("eve-ng-03-spare", {"default": {"id": 99}}) + summary = merge_librenms_links(winner, donor, "default") + + assert winner.custom_field_data["librenms_id"]["default"]["id"] == 42 + assert winner.custom_field_data["librenms_id"]["default"]["oob"] == {"id": 99, "type": "oob"} + assert summary["donor_id_demoted_to_oob"] == {"id": 99, "type": "oob"} + + def test_winner_inherits_donor_oob_when_winner_has_none(self): + from netbox_librenms_plugin.utils import merge_librenms_links + + winner = self._make_dev("eve-ng-02", {"default": {"id": 42}}) + donor = self._make_dev("eve-ng-02-old", {"default": {"oob": {"id": 77, "type": "ipmi"}}}) + summary = merge_librenms_links(winner, donor, "default") + + assert winner.custom_field_data["librenms_id"]["default"]["oob"] == {"id": 77, "type": "ipmi"} + assert summary["oob_from_donor"] == {"id": 77, "type": "ipmi"} + + def test_winner_oob_never_overwritten(self): + from netbox_librenms_plugin.utils import merge_librenms_links + + winner = self._make_dev("eve-ng-02", {"default": {"id": 42, "oob": {"id": 11, "type": "drac"}}}) + donor = self._make_dev("eve-ng-02-old", {"default": {"oob": {"id": 77, "type": "ipmi"}}}) + summary = merge_librenms_links(winner, donor, "default") + + assert winner.custom_field_data["librenms_id"]["default"]["oob"] == {"id": 11, "type": "drac"} + assert summary["oob_from_donor"] is None + + def test_legacy_bare_int_raises(self): + import pytest + + from netbox_librenms_plugin.utils import merge_librenms_links + + winner = MagicMock() + winner.custom_field_data = {"librenms_id": 42} + donor = self._make_dev("idrac-x", {"default": {"id": 99}}) + with pytest.raises(ValueError): + merge_librenms_links(winner, donor, "default") + + def test_malformed_donor_id_raises_clear_error_in_inherit_branch(self): + """coerce_librenms_id raises ValueError with a clear message for non-numeric donor ids.""" + import pytest + + from netbox_librenms_plugin.utils import merge_librenms_links + + winner = self._make_dev("eve-ng-02", {"default": {}}) + donor = self._make_dev("router-spare", {"default": {"id": "not-a-number"}}) + with pytest.raises(ValueError, match="unparseable librenms_id"): + merge_librenms_links(winner, donor, "default") + + def test_malformed_donor_id_raises_clear_error_in_demote_branch(self): + """Same clear error when demoting donor id into winner's oob slot.""" + import pytest + + from netbox_librenms_plugin.utils import merge_librenms_links + + winner = self._make_dev("eve-ng-02", {"default": {"id": 42}}) + donor = self._make_dev("idrac-jhw6nc4", {"default": {"id": "bad"}}) + with pytest.raises(ValueError, match="unparseable librenms_id"): + merge_librenms_links(winner, donor, "default") + + +class TestMarkLibreNMSMigrated: + """Tests for mark_librenms_migrated().""" + + def test_clears_id_and_oob_and_writes_marker(self): + from netbox_librenms_plugin.utils import mark_librenms_migrated + + donor = MagicMock() + donor.custom_field_data = {"librenms_id": {"default": {"id": 99, "oob": {"id": 11, "type": "drac"}}}} + mark_librenms_migrated(donor, winner_pk=42, server_key="default", at="2025-01-01T00:00:00Z") + + entry = donor.custom_field_data["librenms_id"]["default"] + assert "id" not in entry + assert "oob" not in entry + assert entry["_migrated_to"] == { + "device_id": 42, + "server_key": "default", + "at": "2025-01-01T00:00:00Z", + } + + def test_default_timestamp_is_iso_z(self): + from netbox_librenms_plugin.utils import mark_librenms_migrated + + donor = MagicMock() + donor.custom_field_data = {"librenms_id": {"default": {"id": 99}}} + mark_librenms_migrated(donor, winner_pk=42, server_key="default") + + ts = donor.custom_field_data["librenms_id"]["default"]["_migrated_to"]["at"] + assert ts.endswith("Z") + assert len(ts) == 20 + + def test_after_marker_find_by_librenms_id_no_longer_matches(self): + """Donor with only _migrated_to should not be returned by find_by_librenms_id.""" + from netbox_librenms_plugin.utils import find_by_librenms_id, mark_librenms_migrated + + donor = MagicMock() + donor.custom_field_data = {"librenms_id": {"default": {"id": 99}}} + donor.cf = donor.custom_field_data + mark_librenms_migrated(donor, winner_pk=42, server_key="default") + + # cf.librenms_id[default] now only has _migrated_to — no id, no oob. + # find_by_librenms_id walks cf via the model query, but logic-wise: simulate + # by directly inspecting the entry. + entry = donor.cf["librenms_id"]["default"] + assert entry.get("id") is None + assert entry.get("oob") is None + # Mock model.objects.filter: should return empty queryset for either id or oob lookup + mock_model = MagicMock() + mock_model.objects.filter.return_value.first.return_value = None + assert find_by_librenms_id(mock_model, 99, "default") is None diff --git a/netbox_librenms_plugin/tests/test_migrate_views.py b/netbox_librenms_plugin/tests/test_migrate_views.py new file mode 100644 index 000000000..16c054e2b --- /dev/null +++ b/netbox_librenms_plugin/tests/test_migrate_views.py @@ -0,0 +1,394 @@ +""" +Tests for Stage 2b: get_migrated_to_marker helper + the per-row +"Move to winner" view endpoints (MoveInterfaceToWinnerView, +MoveIPAddressToWinnerView, TransferDeviceIPView). + +The view tests use light MagicMock-based plumbing — we patch the model +queryset chain so the views never touch a real database. This is +appropriate because the views' job is glue: validate the marker, look +up the winner, run a small ORM mutation, and return an HTMX response. +""" + +from unittest.mock import MagicMock, patch + + +# ── helper: get_migrated_to_marker ──────────────────────────────────────── + + +class TestGetMigratedToMarker: + def test_returns_marker_when_present_and_well_formed(self): + from netbox_librenms_plugin.utils import get_migrated_to_marker + + donor = MagicMock() + donor.cf = { + "librenms_id": { + "default": {"_migrated_to": {"device_id": 42, "server_key": "default", "at": "2025-01-01T00:00:00Z"}} + } + } + marker = get_migrated_to_marker(donor, "default") + assert marker == {"device_id": 42, "server_key": "default", "at": "2025-01-01T00:00:00Z"} + + def test_returns_none_when_no_cf(self): + from netbox_librenms_plugin.utils import get_migrated_to_marker + + donor = MagicMock() + donor.cf = {} + assert get_migrated_to_marker(donor, "default") is None + + def test_returns_none_when_server_key_missing(self): + from netbox_librenms_plugin.utils import get_migrated_to_marker + + donor = MagicMock() + donor.cf = {"librenms_id": {"primary": {"_migrated_to": {"device_id": 42}}}} + assert get_migrated_to_marker(donor, "default") is None + + def test_returns_none_when_marker_lacks_device_id(self): + from netbox_librenms_plugin.utils import get_migrated_to_marker + + donor = MagicMock() + donor.cf = {"librenms_id": {"default": {"_migrated_to": {"server_key": "default"}}}} + assert get_migrated_to_marker(donor, "default") is None + + def test_returns_none_when_legacy_bare_int(self): + from netbox_librenms_plugin.utils import get_migrated_to_marker + + donor = MagicMock() + donor.cf = {"librenms_id": 42} + assert get_migrated_to_marker(donor, "default") is None + + def test_returns_none_for_none_device(self): + from netbox_librenms_plugin.utils import get_migrated_to_marker + + assert get_migrated_to_marker(None, "default") is None + + +# ── helper: _resolve_winner_for_donor ───────────────────────────────────── + + +class TestResolveWinnerForDonor: + def test_returns_winner_and_marker_when_both_exist(self): + from netbox_librenms_plugin.views.sync.migrate import _resolve_winner_for_donor + + donor = MagicMock() + donor.cf = {"librenms_id": {"default": {"_migrated_to": {"device_id": 42, "server_key": "default", "at": "x"}}}} + winner = MagicMock(pk=42) + + with patch("netbox_librenms_plugin.views.sync.migrate.Device") as mock_device: + mock_device.objects.filter.return_value.first.return_value = winner + result_winner, result_marker = _resolve_winner_for_donor(donor, "default") + + assert result_winner is winner + assert result_marker["device_id"] == 42 + + def test_returns_none_winner_when_winner_deleted(self): + from netbox_librenms_plugin.views.sync.migrate import _resolve_winner_for_donor + + donor = MagicMock() + donor.cf = {"librenms_id": {"default": {"_migrated_to": {"device_id": 42, "server_key": "default", "at": "x"}}}} + + with patch("netbox_librenms_plugin.views.sync.migrate.Device") as mock_device: + mock_device.objects.filter.return_value.first.return_value = None + winner, marker = _resolve_winner_for_donor(donor, "default") + + assert winner is None + assert marker["device_id"] == 42 + + def test_returns_none_when_no_marker(self): + from netbox_librenms_plugin.views.sync.migrate import _resolve_winner_for_donor + + donor = MagicMock() + donor.cf = {} + winner, marker = _resolve_winner_for_donor(donor, "default") + assert winner is None + assert marker is None + + +# ── MoveInterfaceToWinnerView ───────────────────────────────────────────── + + +def _hx_request(post=None): + """Build an HTMX request.""" + req = MagicMock() + post = post or {} + req.POST = MagicMock() + req.POST.get = lambda k, d=None: post.get(k, d) + req.headers = {"HX-Request": "true"} + req.META = {"HTTP_REFERER": "/back"} + req.user = MagicMock(is_superuser=True) + return req + + +class TestMoveInterfaceToWinnerView: + def _setup_view(self): + from netbox_librenms_plugin.views.sync.migrate import MoveInterfaceToWinnerView + + view = MoveInterfaceToWinnerView() + view.require_all_permissions = MagicMock(return_value=None) + return view + + def test_rejects_when_donor_has_no_marker(self): + view = self._setup_view() + req = _hx_request({"server_key": "default"}) + + donor = MagicMock(pk=10, cf={}) + interface = MagicMock(pk=5, name="Eth0", device=donor) + + with ( + patch("netbox_librenms_plugin.views.sync.migrate.get_object_or_404", return_value=interface), + patch( + "netbox_librenms_plugin.views.sync.migrate._resolve_winner_for_donor", + return_value=(None, None), + ), + ): + resp = view.post(req, pk=5) + + assert resp.status_code == 200 + assert b"django-messages" in resp.content + + def test_rejects_on_name_collision(self): + view = self._setup_view() + req = _hx_request({"server_key": "default"}) + + donor = MagicMock(pk=10) + winner = MagicMock(pk=20, name="winner") + interface = MagicMock(pk=5, name="Eth0", device=donor) + + with ( + patch("netbox_librenms_plugin.views.sync.migrate.get_object_or_404", return_value=interface), + patch( + "netbox_librenms_plugin.views.sync.migrate._resolve_winner_for_donor", + return_value=(winner, {"device_id": 20, "server_key": "default", "at": "x"}), + ), + patch("netbox_librenms_plugin.views.sync.migrate.Interface") as mock_iface_cls, + ): + mock_iface_cls.objects.filter.return_value.exists.return_value = True + resp = view.post(req, pk=5) + + assert resp.status_code == 200 + assert b"django-messages" in resp.content + + def test_happy_path_reassigns_device_and_returns_hx_refresh(self): + view = self._setup_view() + req = _hx_request({"server_key": "default"}) + + donor = MagicMock(pk=10) + winner = MagicMock(pk=20, name="winner") + interface = MagicMock(pk=5, name="Eth0", device=donor) + + with ( + patch("netbox_librenms_plugin.views.sync.migrate.get_object_or_404", return_value=interface), + patch( + "netbox_librenms_plugin.views.sync.migrate._resolve_winner_for_donor", + return_value=(winner, {"device_id": 20, "server_key": "default", "at": "x"}), + ), + patch("netbox_librenms_plugin.views.sync.migrate.Interface") as mock_iface_cls, + patch("netbox_librenms_plugin.views.sync.migrate.Device") as mock_device_cls, + patch("netbox_librenms_plugin.views.sync.migrate.transaction"), + patch("netbox_librenms_plugin.views.sync.migrate.messages"), + ): + mock_iface_cls.objects.filter.return_value.exists.return_value = False + mock_iface_cls.objects.filter.return_value.update.return_value = 1 + mock_device_cls.objects.select_for_update.return_value.filter.return_value.order_by.return_value = [] + resp = view.post(req, pk=5) + + # Conditional update (with device=donor guard) used instead of stale instance.save() + mock_iface_cls.objects.filter.assert_called_with(pk=interface.pk, device=donor) + mock_iface_cls.objects.filter.return_value.update.assert_called_once_with(device=winner) + interface.save.assert_not_called() + assert resp.headers.get("HX-Refresh") == "true" + + def test_perm_gate_short_circuits(self): + from django.http import HttpResponse + + from netbox_librenms_plugin.views.sync.migrate import MoveInterfaceToWinnerView + + view = MoveInterfaceToWinnerView() + view.require_all_permissions = MagicMock(return_value=HttpResponse(status=403)) + req = _hx_request() + resp = view.post(req, pk=5) + assert resp.status_code == 403 + + +# ── TransferDeviceIPView ────────────────────────────────────────────────── + + +class TestTransferDeviceIPView: + def _setup_view(self): + from netbox_librenms_plugin.views.sync.migrate import TransferDeviceIPView + + view = TransferDeviceIPView() + view.require_all_permissions = MagicMock(return_value=None) + return view + + def test_unknown_ip_kind_rejected(self): + view = self._setup_view() + req = _hx_request() + resp = view.post(req, pk=10, ip_kind="bogus") + assert resp.status_code == 200 + assert b"django-messages" in resp.content + + def test_rejects_when_winner_already_has_field(self): + view = self._setup_view() + req = _hx_request({"server_key": "default"}) + + donor_ip = MagicMock(address="10.0.0.1/24") + donor = MagicMock(pk=10, primary_ip4=donor_ip) + winner = MagicMock(pk=20, name="winner", primary_ip4=MagicMock(address="10.0.0.99/24")) + + with ( + patch("netbox_librenms_plugin.views.sync.migrate.get_object_or_404", return_value=donor), + patch( + "netbox_librenms_plugin.views.sync.migrate._resolve_winner_for_donor", + return_value=(winner, {"device_id": 20, "server_key": "default", "at": "x"}), + ), + ): + resp = view.post(req, pk=10, ip_kind="primary4") + + assert resp.status_code == 200 + assert b"django-messages" in resp.content + + def test_happy_path_transfers_oob_ip(self): + view = self._setup_view() + req = _hx_request({"server_key": "default"}) + + oob_ip = MagicMock(address="10.0.0.5/24") + donor = MagicMock(pk=10, oob_ip=oob_ip) + winner = MagicMock(pk=20, name="winner", oob_ip=None) + + with ( + patch("netbox_librenms_plugin.views.sync.migrate.get_object_or_404", return_value=donor), + patch( + "netbox_librenms_plugin.views.sync.migrate._resolve_winner_for_donor", + return_value=(winner, {"device_id": 20, "server_key": "default", "at": "x"}), + ), + patch("netbox_librenms_plugin.views.sync.migrate.Device") as mock_device_cls, + patch("netbox_librenms_plugin.views.sync.migrate.transaction"), + patch("netbox_librenms_plugin.views.sync.migrate.messages"), + ): + # Return the already-constructed donor/winner mocks from the locked + # queryset so {d.pk: d for d in ...} builds a dict with both PKs. + mock_device_cls.objects.select_for_update.return_value.filter.return_value.order_by.return_value = [ + donor, + winner, + ] + resp = view.post(req, pk=10, ip_kind="oob") + assert donor.oob_ip is None + assert winner.oob_ip is oob_ip + winner.save.assert_called_once() + donor.save.assert_called_once() + assert resp.headers.get("HX-Refresh") == "true" + + +# ── MoveIPAddressToWinnerView ──────────────────────────────────────────── + + +class TestMoveIPAddressToWinnerView: + def _setup_view(self): + from netbox_librenms_plugin.views.sync.migrate import MoveIPAddressToWinnerView + + view = MoveIPAddressToWinnerView() + view.require_all_permissions = MagicMock(return_value=None) + return view + + def test_rejects_when_ip_not_assigned_to_interface(self): + view = self._setup_view() + req = _hx_request({"server_key": "default"}) + + ip = MagicMock(pk=7) + ip.assigned_object = None # not an Interface + + with patch("netbox_librenms_plugin.views.sync.migrate.get_object_or_404", return_value=ip): + resp = view.post(req, pk=7) + + assert resp.status_code == 200 + assert b"django-messages" in resp.content + + def test_rejects_when_winner_lacks_same_name_interface(self): + from dcim.models import Interface + + from netbox_librenms_plugin.views.sync.migrate import MoveIPAddressToWinnerView + + view = MoveIPAddressToWinnerView() + view.require_all_permissions = MagicMock(return_value=None) + + req = _hx_request({"server_key": "default"}) + + donor = MagicMock(pk=10) + winner = MagicMock(pk=20, name="winner") + donor_iface = MagicMock(spec=Interface) + donor_iface.name = "Eth0" + donor_iface.device = donor + ip = MagicMock(pk=7, address="10.0.0.1/24") + ip.assigned_object = donor_iface + + with ( + patch("netbox_librenms_plugin.views.sync.migrate.get_object_or_404", return_value=ip), + patch( + "netbox_librenms_plugin.views.sync.migrate._resolve_winner_for_donor", + return_value=(winner, {"device_id": 20, "server_key": "default", "at": "x"}), + ), + patch.object(Interface, "objects") as mock_objects, + ): + # exists() drives the pre-check; returns False so the view rejects + # before transaction.atomic() is entered. + mock_objects.filter.return_value.exists.return_value = False + resp = view.post(req, pk=7) + + assert resp.status_code == 200 + assert b"django-messages" in resp.content + + def test_happy_path_reassigns_ip_to_winner_interface(self): + from dcim.models import Device, Interface + from ipam.models import IPAddress as IPAddressModel + + from netbox_librenms_plugin.views.sync.migrate import MoveIPAddressToWinnerView + + view = MoveIPAddressToWinnerView() + view.require_all_permissions = MagicMock(return_value=None) + + req = _hx_request({"server_key": "default"}) + + donor = MagicMock(pk=10, name="donor-device") + winner = MagicMock(pk=20, name="winner-device") + donor_iface = MagicMock(spec=Interface) + donor_iface.name = "Eth0" + donor_iface.device = donor + donor_iface.device_id = 10 + winner_iface = MagicMock(spec=Interface, name="Eth0") + winner_iface.name = "Eth0" + + ip = MagicMock(pk=7, address="10.0.0.1/24") + ip.assigned_object = donor_iface + + locked_donor = MagicMock(pk=10, name="donor-device") + locked_winner = MagicMock(pk=20, name="winner-device") + + with ( + patch("netbox_librenms_plugin.views.sync.migrate.get_object_or_404", return_value=ip), + patch( + "netbox_librenms_plugin.views.sync.migrate._resolve_winner_for_donor", + return_value=(winner, {"device_id": 20, "server_key": "default", "at": "x"}), + ), + patch.object(Interface, "objects") as mock_iface_objects, + patch.object(Device, "objects") as mock_device_objects, + patch.object(IPAddressModel, "objects") as mock_ip_objects, + patch("netbox_librenms_plugin.views.sync.migrate.transaction") as mock_tx, + ): + mock_iface_objects.filter.return_value.exists.return_value = True + mock_iface_objects.select_for_update.return_value.filter.return_value.first.return_value = winner_iface + + locked_list = [locked_donor, locked_winner] + mock_device_objects.select_for_update.return_value.filter.return_value.order_by.return_value = locked_list + + locked_ip = MagicMock(pk=7, address="10.0.0.1/24") + locked_ip.assigned_object = donor_iface + mock_ip_objects.select_for_update.return_value.filter.return_value.first.return_value = locked_ip + + mock_tx.atomic.return_value.__enter__ = MagicMock(return_value=None) + mock_tx.atomic.return_value.__exit__ = MagicMock(return_value=False) + + resp = view.post(req, pk=7) + + assert locked_ip.assigned_object is winner_iface + locked_ip.save.assert_called_once_with(update_fields=["assigned_object_type", "assigned_object_id"]) + assert resp.status_code == 200 diff --git a/netbox_librenms_plugin/urls.py b/netbox_librenms_plugin/urls.py index 922597387..2b1d4d05a 100644 --- a/netbox_librenms_plugin/urls.py +++ b/netbox_librenms_plugin/urls.py @@ -12,8 +12,14 @@ ) from .views import ( AddDeviceToLibreNMSView, + AddAsOOBView, AddDeviceTypeMappingView, AddPlatformMappingView, + PromoteToHostView, + MergeNetBoxDevicesView, + MoveInterfaceToWinnerView, + MoveIPAddressToWinnerView, + TransferDeviceIPView, AssignVCSerialView, BulkImportConfirmView, BulkImportDevicesView, @@ -431,6 +437,36 @@ DeviceConflictActionView.as_view(), name="device_conflict_action", ), + path( + "device-import/add-as-oob//", + AddAsOOBView.as_view(), + name="device_add_as_oob", + ), + path( + "device-import/promote-to-host//", + PromoteToHostView.as_view(), + name="device_promote_to_host", + ), + path( + "device-import/merge-netbox-devices//", + MergeNetBoxDevicesView.as_view(), + name="device_merge_netbox_devices", + ), + path( + "interface//move-to-winner/", + MoveInterfaceToWinnerView.as_view(), + name="interface_move_to_winner", + ), + path( + "ipaddress//move-to-winner/", + MoveIPAddressToWinnerView.as_view(), + name="ipaddress_move_to_winner", + ), + path( + "device//transfer-ip//", + TransferDeviceIPView.as_view(), + name="device_transfer_ip", + ), path( "device-import/add-device-type-mapping//", AddDeviceTypeMappingView.as_view(), diff --git a/netbox_librenms_plugin/utils.py b/netbox_librenms_plugin/utils.py index eb29a86ac..fbbfa00ab 100644 --- a/netbox_librenms_plugin/utils.py +++ b/netbox_librenms_plugin/utils.py @@ -360,9 +360,18 @@ def get_librenms_sync_device(device: Device, server_key: str = None) -> Optional all_members = vc.members.all() def _is_valid_librenms_id(val): - # Reject None, booleans, and anything that doesn't coerce to a positive int. + # Reject None and booleans. if val is None or isinstance(val, bool): return False + # Handle new per-server dict form: {"id": 42, "oob": {...}} + if isinstance(val, dict): + inner = val.get("id") + if inner is None or isinstance(inner, bool): + return False + try: + return int(inner) > 0 + except (TypeError, ValueError): + return False try: return int(val) > 0 except (TypeError, ValueError): @@ -903,6 +912,28 @@ def check_vlan_group_matches( return True +def coerce_librenms_id(value) -> int | None: + """Coerce a raw LibreNMS ID value (int or string-digit) to int, or None. + + Accepts only ``int`` and ``str`` — other types (None, dicts, MagicMocks, + etc.) return None. Booleans are rejected because ``bool`` is a subclass + of ``int`` in Python, so ``int(True)`` silently becomes ``1`` — a + valid-looking device ID. Zero and negative values are also rejected since + LibreNMS IDs are strictly positive integers. + """ + if isinstance(value, bool): + return None + if isinstance(value, int): + return value if value > 0 else None + if isinstance(value, str): + try: + coerced = int(value) + return coerced if coerced > 0 else None + except ValueError: + return None + return None + + def get_librenms_device_id(obj, server_key: str = "default", *, auto_save: bool = True): """ Get the LibreNMS device/port ID for a specific server from the JSON custom field. @@ -951,6 +982,26 @@ def get_librenms_device_id(obj, server_key: str = "default", *, auto_save: bool value = cf_value.get(server_key) if isinstance(value, bool): return None + if isinstance(value, dict): + # New form: {"id": 42, "oob": {...}} — extract the main device id. + inner = value.get("id") + if isinstance(inner, bool): + return None + if isinstance(inner, int): + return inner if inner > 0 else None + if isinstance(inner, str): + try: + int_id = int(inner) + except (ValueError, TypeError): + return None + if int_id <= 0: + return None + if auto_save: + value["id"] = int_id + obj.custom_field_data["librenms_id"] = cf_value + obj.save(update_fields=["custom_field_data"]) + return int_id + return None if isinstance(value, str): # Normalise string-stored ID inside JSON dict and write back. try: @@ -1024,7 +1075,7 @@ def set_librenms_device_id(obj, device_id, server_key: str = "default"): ) cf_value = {} try: - cf_value[server_key] = int(device_id) + int_id = int(device_id) except (TypeError, ValueError): logger.warning( "librenms_id device_id %r is not a valid integer on %r; not storing.", @@ -1032,6 +1083,19 @@ def set_librenms_device_id(obj, device_id, server_key: str = "default"): obj, ) return # Don't persist an invalid entry + if int_id <= 0: + logger.warning( + "librenms_id device_id %r must be a positive integer on %r; not storing.", + device_id, + obj, + ) + return + # Preserve any existing OOB sub-object when rewriting the main device id. + existing_entry = cf_value.get(server_key) + if isinstance(existing_entry, dict) and "oob" in existing_entry: + cf_value[server_key] = {"id": int_id, "oob": existing_entry["oob"]} + else: + cf_value[server_key] = int_id obj.custom_field_data["librenms_id"] = cf_value @@ -1070,6 +1134,12 @@ def find_by_librenms_id(model, librenms_id, server_key: str = "default"): q = Q(**{f"custom_field_data__librenms_id__{server_key}": librenms_id}) # Also match when the namespaced value was stored as a string (e.g. {"production": "42"}). q |= Q(**{f"custom_field_data__librenms_id__{server_key}": str(librenms_id)}) + # Match when value stored as {"id": librenms_id, "oob": {...}} — new dict-with-id form. + q |= Q(**{f"custom_field_data__librenms_id__{server_key}__id": librenms_id}) + q |= Q(**{f"custom_field_data__librenms_id__{server_key}__id": str(librenms_id)}) + # Match when librenms_id is the OOB device id — so re-import recognises merged device. + q |= Q(**{f"custom_field_data__librenms_id__{server_key}__oob__id": librenms_id}) + q |= Q(**{f"custom_field_data__librenms_id__{server_key}__oob__id": str(librenms_id)}) # Always include legacy bare-integer and bare-string IDs as a universal fallback. # Legacy records were created before multi-server support; they should be visible # regardless of which server is currently active. @@ -1086,6 +1156,10 @@ def find_by_librenms_id(model, librenms_id, server_key: str = "default"): canonical_str = str(int_value) q |= Q(**{f"custom_field_data__librenms_id__{server_key}": canonical_str}) q |= Q(**{f"custom_field_data__librenms_id__{server_key}": int_value}) + q |= Q(**{f"custom_field_data__librenms_id__{server_key}__id": canonical_str}) + q |= Q(**{f"custom_field_data__librenms_id__{server_key}__id": int_value}) + q |= Q(**{f"custom_field_data__librenms_id__{server_key}__oob__id": canonical_str}) + q |= Q(**{f"custom_field_data__librenms_id__{server_key}__oob__id": int_value}) q |= Q(custom_field_data__librenms_id=canonical_str) q |= Q(custom_field_data__librenms_id=int_value) except ValueError: @@ -1093,6 +1167,136 @@ def find_by_librenms_id(model, librenms_id, server_key: str = "default"): return model.objects.filter(q).first() +def get_librenms_oob(obj, server_key: str = "default") -> dict | None: + """ + Return the OOB sub-object from the ``librenms_id`` JSON custom field, or ``None``. + + Read-only — never triggers a DB write. Returns the raw ``oob`` dict verbatim so + callers can inspect ``id``, ``type``, ``version``, and ``ip`` without additional helpers. + + Returns ``None`` when: + - the field is absent, a legacy bare integer, or not a dict; + - the server-key entry is a bare integer (no OOB attached); + - the ``oob`` key is missing or not a dict. + + Args: + obj: NetBox object with a ``librenms_id`` custom field. + server_key: LibreNMS server key (from plugin ``servers`` config). + + Returns: + dict or None + """ + cf_value = obj.cf.get("librenms_id") + if not isinstance(cf_value, dict): + return None + entry = cf_value.get(server_key) + if not isinstance(entry, dict): + return None + oob = entry.get("oob") + return oob if isinstance(oob, dict) else None + + +def set_librenms_oob( + obj, + oob_device_id: int, + server_key: str = "default", + *, + oob_type: str, + version: str | None = None, + ip: str | None = None, +) -> None: + """ + Attach an OOB management controller to a device under *server_key*. + + Promotes the server-key value to the ``{"id": N, "oob": {...}}`` dict form if it is + currently a bare integer. Validates *oob_type* against ``OOB_TYPE_PATTERN`` or accepts + the generic sentinel ``"oob"`` (used when no specific type keyword can be detected). + + Does **not** call ``obj.save()`` — the caller is responsible for persisting the change. + + Args: + obj: NetBox object with a ``librenms_id`` custom field. + oob_device_id: LibreNMS device ID of the OOB controller. + server_key: LibreNMS server key (from plugin ``servers`` config). + oob_type: Raw type string (e.g. ``"iDRAC9"``, ``"ilo"``, or the generic ``"oob"``). + Will be normalized to lowercase. + version: Optional firmware/software version string. + ip: Optional IP address string of the OOB controller. + + Raises: + ValueError: if *oob_type* does not match any known OOB type and is not the + generic ``"oob"`` sentinel. + """ + from netbox_librenms_plugin.constants import OOB_TYPE_PATTERN, OOB_TYPES + + _type_normalized = (oob_type or "").strip().lower() + if _type_normalized == "oob": + # Generic sentinel: OOB relationship confirmed but specific controller type unknown. + normalized_type = "oob" + elif not (match := OOB_TYPE_PATTERN.search(_type_normalized)): + raise ValueError(f"oob_type {oob_type!r} does not match any known OOB type {OOB_TYPES}") + else: + normalized_type = match.group(1).lower() + + # Validate the OOB device ID: reject booleans (int subclass), zero, and negatives. + if isinstance(oob_device_id, bool) or not isinstance(oob_device_id, (int, str)): + raise ValueError(f"oob_device_id must be a positive integer, got {oob_device_id!r}") + _oob_id = coerce_librenms_id(oob_device_id) + if _oob_id is None: + raise ValueError(f"oob_device_id must be a positive integer, got {oob_device_id!r}") + + cf_value = obj.custom_field_data.get("librenms_id") or {} + if not isinstance(cf_value, dict): + logger.warning("librenms_id on %r is not a dict; cannot set OOB.", obj) + return + + entry = cf_value.get(server_key) + if isinstance(entry, int) and not isinstance(entry, bool): + # Promote bare int to dict form, preserving the main device id. + entry = {"id": entry} + elif isinstance(entry, str): + coerced = coerce_librenms_id(entry) + entry = {"id": coerced} if coerced else {} + elif isinstance(entry, dict): + entry = dict(entry) # shallow copy so we don't mutate the stored dict in-place + else: + entry = {} + + oob: dict = {"id": _oob_id, "type": normalized_type} + if version: + oob["version"] = version + if ip: + oob["ip"] = ip + entry["oob"] = oob + cf_value[server_key] = entry + obj.custom_field_data["librenms_id"] = cf_value + + +def clear_librenms_oob(obj, server_key: str = "default") -> None: + """ + Remove the OOB sub-object from the server-key entry of ``librenms_id``. + + The entry is left in ``{"id": N}`` object form — it is NOT demoted back to a bare + integer (either form is valid; keeping object form avoids an extra save). + + Does **not** call ``obj.save()`` — the caller is responsible for persisting the change. + Is a no-op when the server-key entry has no ``oob`` sub-key. + + Args: + obj: NetBox object with a ``librenms_id`` custom field. + server_key: LibreNMS server key (from plugin ``servers`` config). + """ + cf_value = obj.custom_field_data.get("librenms_id") + if not isinstance(cf_value, dict): + return + entry = cf_value.get(server_key) + if not isinstance(entry, dict): + return + entry.pop("oob", None) + cf_value[server_key] = entry + obj.custom_field_data["librenms_id"] = cf_value + + def migrate_legacy_librenms_id(obj, server_key: str = "default") -> bool: """ Migrate a legacy bare-integer ``librenms_id`` custom field to the JSON dict format, @@ -1176,6 +1380,182 @@ def netbox_resolves_module_token_per_leaf(): return version >= _MODULE_TOKEN_LEAF_FIX_VERSION +def merge_librenms_links(winner, donor, server_key: str = "default") -> dict: + """ + Merge donor's ``librenms_id[server_key]`` link state into winner's. + + Used by the Stage-2 "two NetBox devices represent the same physical box" + flow. This function **only mutates the winner's** ``custom_field_data``. + The donor is not modified here — callers must call + :func:`mark_librenms_migrated` separately (and save both objects + themselves) to clear the donor's active link and stamp the + ``_migrated_to`` marker. + + Conflict policy (winner-wins for already-populated fields): + + * If winner already has ``id`` set, donor's ``id`` is moved into the + ``oob`` slot (only when winner has no ``oob`` yet, with type derived + from the donor's name when possible). + * If winner has no ``id`` and donor does, winner inherits ``id``. + * If donor has an ``oob`` sub-block and winner has none, winner + inherits it verbatim. + * Winner's existing ``oob`` is never overwritten. + + Args: + winner: NetBox Device that will hold the merged link state. + donor: NetBox Device whose link state will be absorbed. + server_key: LibreNMS server key to scope the merge to. + + Returns: + A dict describing what was actually merged: keys ``host_id_from_donor``, + ``oob_from_donor`` (None or dict), ``donor_id_demoted_to_oob`` + (None or dict). Useful for audit logging and tests. + """ + from netbox_librenms_plugin.constants import OOB_TYPE_PATTERN + + summary = { + "host_id_from_donor": None, + "oob_from_donor": None, + "donor_id_demoted_to_oob": None, + } + + winner_cf = winner.custom_field_data.get("librenms_id") or {} + donor_cf = donor.custom_field_data.get("librenms_id") or {} + if not isinstance(winner_cf, dict) or not isinstance(donor_cf, dict): + # Legacy bare-int forms must be migrated by the caller before merging. + raise ValueError("Cannot merge: one or both devices have a legacy bare-integer librenms_id.") + + winner_entry = winner_cf.get(server_key) + if isinstance(winner_entry, int) and not isinstance(winner_entry, bool): + winner_entry = {"id": winner_entry} + elif isinstance(winner_entry, str): + _coerced = coerce_librenms_id(winner_entry) + winner_entry = {"id": _coerced} if _coerced else {} + elif isinstance(winner_entry, dict): + winner_entry = dict(winner_entry) + else: + winner_entry = {} + + donor_entry = donor_cf.get(server_key) + if isinstance(donor_entry, int) and not isinstance(donor_entry, bool): + donor_entry = {"id": donor_entry} + elif isinstance(donor_entry, str): + _coerced = coerce_librenms_id(donor_entry) + donor_entry = {"id": _coerced} if _coerced else {} + elif not isinstance(donor_entry, dict): + donor_entry = {} + + donor_id = donor_entry.get("id") + donor_oob = donor_entry.get("oob") if isinstance(donor_entry.get("oob"), dict) else None + + # Coerce both IDs before branching so that a malformed but truthy winner_id + # (e.g. "abc") does not incorrectly trigger the "demote donor" path. + _raw_winner_id = winner_entry.get("id") + _raw_donor_id = donor_id + winner_id = coerce_librenms_id(_raw_winner_id) if _raw_winner_id is not None else None + donor_id = coerce_librenms_id(_raw_donor_id) if _raw_donor_id is not None else None + if _raw_winner_id is not None and winner_id is None: + raise ValueError( + f"winner '{winner.name}' has an unparseable librenms_id[{server_key!r}] id " + f"{_raw_winner_id!r} — expected a positive integer or numeric string." + ) + if _raw_donor_id is not None and donor_id is None: + raise ValueError( + f"donor '{donor.name}' has an unparseable librenms_id[{server_key!r}] id " + f"{_raw_donor_id!r} — expected a positive integer or numeric string." + ) + winner_oob = winner_entry.get("oob") if isinstance(winner_entry.get("oob"), dict) else None + + if winner_id is None and donor_id is not None: + winner_entry["id"] = donor_id + summary["host_id_from_donor"] = donor_id + elif winner_id is not None and donor_id is not None and winner_oob is None: + # Demote donor's host id into winner's oob slot. Infer type from donor name. + match = OOB_TYPE_PATTERN.search(donor.name or "") + inferred_type = match.group(1).lower() if match else "oob" + demoted = {"id": donor_id, "type": inferred_type} + winner_entry["oob"] = demoted + summary["donor_id_demoted_to_oob"] = demoted + winner_oob = demoted + + if donor_oob and winner_oob is None: + winner_entry["oob"] = dict(donor_oob) + summary["oob_from_donor"] = dict(donor_oob) + + winner_cf[server_key] = winner_entry + winner.custom_field_data["librenms_id"] = winner_cf + return summary + + +def mark_librenms_migrated(donor, winner_pk: int, server_key: str = "default", at: str | None = None) -> None: + """ + Mark *donor* as migrated to the device with primary key *winner_pk*. + + Removes any active ``id`` / ``oob`` keys from ``donor.custom_field_data + ['librenms_id'][server_key]`` (so the device is no longer matched by + ``find_by_librenms_id``) and writes a ``_migrated_to`` sub-key with the + target device pk, server key, and ISO-8601 UTC timestamp. + + Does **not** call ``donor.save()`` — caller is responsible for persisting. + + Args: + donor: NetBox Device being absorbed by the winner. + winner_pk: Primary key of the winning device. + server_key: LibreNMS server key whose link state is being cleared. + at: ISO timestamp string. When None, ``datetime.utcnow().isoformat()`` + with a ``Z`` suffix is used. + """ + from datetime import datetime, timezone + + cf_value = donor.custom_field_data.get("librenms_id") or {} + if not isinstance(cf_value, dict): + cf_value = {} + entry = cf_value.get(server_key) + if isinstance(entry, int) and not isinstance(entry, bool): + entry = {"id": entry} + elif not isinstance(entry, dict): + entry = {} + entry.pop("id", None) + entry.pop("oob", None) + entry["_migrated_to"] = { + "device_id": int(winner_pk), + "server_key": server_key, + "at": at or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + } + cf_value[server_key] = entry + donor.custom_field_data["librenms_id"] = cf_value + + +def get_migrated_to_marker(device, server_key: str = "default") -> dict | None: + """ + Read the ``_migrated_to`` marker (Stage 2b) from the device's + ``librenms_id[server_key]`` sub-block. + + Returns the marker dict ``{device_id, server_key, at}`` when the donor + was previously merged into another NetBox device via + :func:`mark_librenms_migrated`, or ``None`` when no marker is present + (or the cf is malformed). + + Used by the librenms-sync UI to switch a donor device into "migrated + mode": disable sync actions and surface per-row "Move to winner" + buttons. + """ + if device is None: + return None + cf_value = device.cf.get("librenms_id") if hasattr(device, "cf") else None + if not isinstance(cf_value, dict): + return None + entry = cf_value.get(server_key) + if not isinstance(entry, dict): + return None + marker = entry.get("_migrated_to") + if not isinstance(marker, dict): + return None + if not isinstance(marker.get("device_id"), int): + return None + return marker + + def has_nested_name_conflict(module_type, module_bay, sibling_counts=None): """ Check if installing this module type in a nested bay would cause a name conflict. diff --git a/netbox_librenms_plugin/views/__init__.py b/netbox_librenms_plugin/views/__init__.py index 4035b0297..5f1462a3c 100644 --- a/netbox_librenms_plugin/views/__init__.py +++ b/netbox_librenms_plugin/views/__init__.py @@ -110,8 +110,13 @@ PlatformMappingListView, PlatformMappingView, ) -from .imports.actions import AddDeviceTypeMappingView # noqa: F401 -from .imports.actions import AddPlatformMappingView # noqa: F401 +from .imports.actions import ( # noqa: F401 + AddAsOOBView, + AddDeviceTypeMappingView, + AddPlatformMappingView, + MergeNetBoxDevicesView, + PromoteToHostView, +) from .object_sync import ( # noqa: F401 DeviceCableTableView, DeviceInterfaceTableView, @@ -145,4 +150,9 @@ from .sync.interfaces import DeleteNetBoxInterfacesView, SyncInterfacesView # noqa: F401 from .sync.ip_addresses import SyncIPAddressesView # noqa: F401 from .sync.locations import SyncSiteLocationView # noqa: F401 +from .sync.migrate import ( # noqa: F401 + MoveInterfaceToWinnerView, + MoveIPAddressToWinnerView, + TransferDeviceIPView, +) from .sync.vlans import SyncVLANsView # noqa: F401 diff --git a/netbox_librenms_plugin/views/base/cables_view.py b/netbox_librenms_plugin/views/base/cables_view.py index 969400143..c1452fb33 100644 --- a/netbox_librenms_plugin/views/base/cables_view.py +++ b/netbox_librenms_plugin/views/base/cables_view.py @@ -13,6 +13,7 @@ from netbox_librenms_plugin.utils import ( get_interface_name_field, + get_librenms_oob, get_librenms_sync_device, get_virtual_chassis_member, ) @@ -109,8 +110,47 @@ def get_links_data(self, obj): "remote_device": link.get("remote_hostname"), "remote_port_id": link.get("remote_port_id"), "remote_device_id": link.get("remote_device_id"), + "_source": "main", } ) + + # If an OOB controller is linked, fetch its LLDP links and merge. + lookup_device = get_librenms_sync_device(obj, server_key=self.librenms_api.server_key) or obj + oob = get_librenms_oob(lookup_device, server_key=self.librenms_api.server_key) + if oob and oob.get("id"): + oob_success, oob_data = self.librenms_api.get_device_links(oob["id"]) + if oob_success and "error" not in oob_data: + # Build a port-id → name map for the OOB device using the same + # interface_name_field as the main device so names are consistent. + oob_ports_success, oob_ports_data = self.librenms_api.get_ports(oob["id"]) + oob_local_ports_map = {} + if oob_ports_success: + for port in (oob_ports_data or {}).get("ports", []): + raw_port_id = port.get("port_id") + if raw_port_id is None: + continue + port_name = port.get(interface_name_field) + if port_name is None: + continue + oob_local_ports_map[str(raw_port_id)] = port_name + + for link in oob_data.get("links", []): + oob_port_id = link.get("local_port_id") + oob_local_port = oob_local_ports_map.get(str(oob_port_id)) if oob_port_id else None + if oob_local_port is None: + oob_local_port = link.get("local_port") + links_data.append( + { + "local_port": oob_local_port, + "local_port_id": oob_port_id, + "remote_port": link.get("remote_port"), + "remote_device": link.get("remote_hostname"), + "remote_port_id": link.get("remote_port_id"), + "remote_device_id": link.get("remote_device_id"), + "_source": "oob", + } + ) + return links_data def get_device_by_id_or_name(self, remote_device_id, hostname, server_key=None): @@ -340,6 +380,7 @@ def _prepare_context(self, request, obj, fetch_fresh=False): "remote_device", "remote_port_id", "remote_device_id", + "_source", } links_data = [{k: v for k, v in link.items() if k in _raw_keys} for link in links_data] @@ -471,6 +512,7 @@ def post(self, request): "remote_device", "remote_port_id", "remote_device_id", + "_source", } link_data = {k: v for k, v in link_data.items() if k in _raw_keys} diff --git a/netbox_librenms_plugin/views/base/interfaces_view.py b/netbox_librenms_plugin/views/base/interfaces_view.py index b3f3741b2..746015938 100644 --- a/netbox_librenms_plugin/views/base/interfaces_view.py +++ b/netbox_librenms_plugin/views/base/interfaces_view.py @@ -6,6 +6,7 @@ from netbox_librenms_plugin.utils import ( get_interface_name_field, + get_librenms_oob, get_virtual_chassis_member, normalize_librenms_port_id, ) @@ -123,9 +124,40 @@ def post(self, request, pk): # Enrich ports with VLAN data for trunk ports ports = librenms_data.get("ports", []) enriched_ports = self._enrich_ports_with_vlan_data(ports, interface_name_field) + for port in enriched_ports: + port["_source"] = "main" librenms_data["ports"] = enriched_ports + # If an OOB controller is linked, fetch its ports and merge them in. _server_key = self.librenms_api.server_key + oob = get_librenms_oob(obj, server_key=_server_key) + if oob and oob.get("id"): + oob_success, oob_raw = self.librenms_api.get_ports(oob["id"]) + if oob_success: + oob_ports = oob_raw.get("ports", []) + oob_enriched = self._enrich_ports_with_vlan_data(oob_ports, interface_name_field) + for port in oob_enriched: + port["_source"] = "oob" + # Detect shared-LOM: same MAC seen on BOTH main and OOB sides. + # Build separate per-source MAC sets so that within-source + # duplicates are not falsely flagged as cross-source conflicts. + main_macs: set[str] = set() + for port in enriched_ports: + mac = (port.get("ifPhysAddress") or "").lower().strip() + if mac: + main_macs.add(mac) + oob_macs: set[str] = set() + for port in oob_enriched: + mac = (port.get("ifPhysAddress") or "").lower().strip() + if mac: + oob_macs.add(mac) + shared_macs = main_macs & oob_macs + if shared_macs: + for port in enriched_ports + oob_enriched: + mac = (port.get("ifPhysAddress") or "").lower().strip() + if mac in shared_macs: + port["_dedup_conflict"] = True + librenms_data["ports"] = enriched_ports + oob_enriched # Store data in cache (keyed by server to avoid cross-server collisions) cache.set( self.get_cache_key(obj, "ports", _server_key), @@ -219,7 +251,8 @@ def get_context_data(self, request, obj, interface_name_field, server_key=None): if hasattr(obj, "virtual_chassis") and obj.virtual_chassis: chassis_member = get_virtual_chassis_member(obj, port.get(interface_name_field)) device_interfaces = interfaces_by_device.get( - chassis_member.id, {"by_name": {}, "by_librenms_id": {}} + chassis_member.id if chassis_member else obj.id, + {"by_name": {}, "by_librenms_id": {}}, ) else: device_interfaces = interfaces_by_device.get(obj.id, {"by_name": {}, "by_librenms_id": {}}) diff --git a/netbox_librenms_plugin/views/base/librenms_sync_view.py b/netbox_librenms_plugin/views/base/librenms_sync_view.py index ee9bf57fb..34b4f5cd2 100644 --- a/netbox_librenms_plugin/views/base/librenms_sync_view.py +++ b/netbox_librenms_plugin/views/base/librenms_sync_view.py @@ -12,6 +12,7 @@ get_interface_name_field, get_librenms_device_id, get_librenms_sync_device, + get_migrated_to_marker, match_librenms_hardware_to_device_type, resolve_naming_preferences, ) @@ -154,11 +155,41 @@ def get_context_data(self, request, obj): _lookup_device._meta.model_name if _lookup_device else obj._meta.model_name ), "object_model_name": obj._meta.model_name, + **self._build_migrated_context(_lookup_device, self.librenms_api.server_key), } ) return context + @staticmethod + def _build_migrated_context(obj, server_key): + """ + Build Stage 2b "donor migrated mode" context. + + Returns a dict with: + * ``migrated_to_marker`` — the marker dict (``{device_id, server_key, at}``) + when this device was previously merged into another via + :func:`mark_librenms_migrated`, else ``None``. + * ``migrated_to_winner`` — the winner :class:`Device` instance (or + ``None`` if it has been deleted since the marker was written). + + When ``migrated_to_marker`` is set, all sync action buttons should + be hidden and per-row "Move to winner" actions should be shown + instead. + """ + marker = get_migrated_to_marker(obj, server_key) + if not marker: + return {"migrated_to_marker": None, "migrated_to_winner": None} + + from dcim.models import Device + + try: + winner_pk = int(marker.get("device_id")) + except (TypeError, ValueError): + return {"migrated_to_marker": marker, "migrated_to_winner": None} + winner = Device.objects.filter(pk=winner_pk).first() + return {"migrated_to_marker": marker, "migrated_to_winner": winner} + @staticmethod def _build_all_server_mappings(obj, active_server_key): """ diff --git a/netbox_librenms_plugin/views/base/modules_view.py b/netbox_librenms_plugin/views/base/modules_view.py index 354236944..aca8cc643 100644 --- a/netbox_librenms_plugin/views/base/modules_view.py +++ b/netbox_librenms_plugin/views/base/modules_view.py @@ -9,6 +9,7 @@ from netbox_librenms_plugin.utils import ( get_librenms_device_id, + get_librenms_oob, get_librenms_sync_device, get_module_template_interface_names, normalize_librenms_port_id, @@ -52,6 +53,19 @@ _NON_HARDWARE_CLASSES = {"sensor", "backplane", "stack"} +def _try_int(v: object) -> int | None: + """Return int(v), or None if v is not coercible to int. + + LibreNMS API responses may return numeric SNMP indices as strings. + This helper lets callers safely coerce without crashing on unexpected + non-numeric values such as empty strings or "N/A". + """ + try: + return int(v) # type: ignore[arg-type] + except (TypeError, ValueError): + return None + + def _check_ignore_rules( item: dict, parent_item: dict | None, @@ -310,6 +324,9 @@ def post(self, request, pk): }, ) + for item in inventory_data: + item["_source"] = "main" + # Fetch ports once and reuse in subsequent enrichment steps. ports_success, ports_data = self.librenms_api.get_ports(self.librenms_id) ports_error = None @@ -317,12 +334,54 @@ def post(self, request, pk): ports_error = str(ports_data) if ports_data else "unknown error" ports_data = {} - # Fetch transceiver data and merge with inventory + # Merge main-device transceiver data BEFORE computing the OOB offset so + # that synthetic transceiver rows (whose entity_physical_index values come + # from the transceiver API and may exceed the ENTITY-MIB high-water mark) + # are counted in main_max_idx. Vendors like Nokia expose SFPs in the + # transceiver API with indices that are absent from ENTITY-MIB; without + # this reordering those high indices could fall inside the OOB namespace. inventory_data, txr_error = self._merge_transceiver_data(inventory_data, ports_data=ports_data) + for item in inventory_data: + item.setdefault("_source", "main") # Enrich port rows with stable LibreNMS port_id using ports data so # interface matching works even when transceiver metadata is absent. self._enrich_inventory_port_identity(inventory_data, ports_data=ports_data) + # If an OOB controller is linked, fetch its inventory and merge. + # Offset OOB entPhysicalIndex values by a dynamic amount to prevent + # collisions with the main device's real entPhysicalIndex values. + # RFC 2737 does not cap entPhysicalIndex, so a static 1_000_000 offset + # is not safe for high-density chassis. Instead we compute an offset + # that is always above the main device's highest observed index, + # including any synthetic transceiver rows added above. + _server_key = self.librenms_api.server_key + oob = get_librenms_oob(sync_device, server_key=_server_key) + oob_failed = False + if oob and oob.get("id"): + oob_success, oob_inventory = self.librenms_api.get_device_inventory(oob["id"]) + if oob_success: + main_max_idx = max( + (cast for item in inventory_data if (cast := _try_int(item.get("entPhysicalIndex"))) is not None), + default=0, + ) + # Round up to the next 1000-boundary for a clean namespace. + _OOB_OFFSET = ((main_max_idx // 1000) + 1) * 1000 + for item in oob_inventory: + item["_source"] = "oob" + if (idx := _try_int(item.get("entPhysicalIndex"))) is not None: + item["entPhysicalIndex"] = idx + _OOB_OFFSET + if (parent := _try_int(item.get("entPhysicalContainedIn"))) is not None and parent != 0: + item["entPhysicalContainedIn"] = parent + _OOB_OFFSET + inventory_data = inventory_data + oob_inventory + else: + oob_failed = True + logger.warning( + "OOB inventory fetch failed for device %s (OOB id %s): %s", + self.librenms_id, + oob["id"], + oob_inventory, + ) + # Cache the merged inventory data, namespaced by server and librenms_id to detect remapping cache.set( self.get_cache_key(sync_device, "inventory", server_key=self.librenms_api.server_key), @@ -341,7 +400,12 @@ def post(self, request, pk): if txr_error: logger.warning("Transceiver fetch failed for device %s: %s", self.librenms_id, txr_error) messages.warning(request, "Inventory refreshed, but transceiver fetch failed; see server logs for details.") - elif not ports_error: + if oob_failed: + messages.warning( + request, + f"Inventory refreshed, but OOB controller inventory fetch failed (device {self.librenms_id}, OOB id {oob['id']}); see server logs for details.", + ) + if not txr_error and not oob_failed and not ports_error: messages.success(request, "Inventory data refreshed successfully.") return render( request, @@ -2116,6 +2180,7 @@ def _build_row( "has_installable_children": False, "integrated_in_name": ancestor_name, "integrated_in_index": integrating_ancestor.get("entPhysicalIndex"), + "_source": item.get("_source", "main"), } # Match to NetBox module bay @@ -2161,6 +2226,7 @@ def _build_row( "librenms_ifname": item.get("_librenms_ifname"), "librenms_ifdescr": item.get("_librenms_ifdescr"), "interface_name_hint": item.get("_librenms_ifname") or item.get("_librenms_ifdescr"), + "_source": item.get("_source", "main"), } if name_conflict_reason: row["name_conflict_reason"] = name_conflict_reason diff --git a/netbox_librenms_plugin/views/imports/actions.py b/netbox_librenms_plugin/views/imports/actions.py index f0ccd016c..c74444cb3 100644 --- a/netbox_librenms_plugin/views/imports/actions.py +++ b/netbox_librenms_plugin/views/imports/actions.py @@ -2,6 +2,7 @@ import json import logging +from ipaddress import ip_address as _ipaddr_parse from urllib.parse import parse_qs, urlparse from django.contrib import messages @@ -11,6 +12,7 @@ from django.http import HttpResponse, JsonResponse from django.shortcuts import redirect, render +from django.template.loader import render_to_string from django.utils.html import escape, format_html from django.utils.safestring import mark_safe from django.utils.text import slugify @@ -20,9 +22,11 @@ _determine_device_name, bulk_import_devices, bulk_import_vms, + detect_bulk_collisions, fetch_device_with_cache, get_import_device_cache_key, get_librenms_device_by_id, + get_or_create_global_ip, get_virtual_chassis_data, update_vc_member_suggested_names, validate_device_for_import, @@ -44,6 +48,34 @@ logger = logging.getLogger(__name__) + +def _attach_messages_oob(response, request): + """ + Append a single OOB-swap toast container to an HTMX response. + + NetBox's standard ``inc/messages.html`` renders a + ``
`` with one Bootstrap toast + per pending Django message. Including this snippet inside per-row partials + causes problems on multi-row OOB responses because each render emits a + matching ``id="django-messages"`` div and the LAST swap (typically empty + once messages have been consumed by an earlier render) wipes the toasts. + + Centralising the include here guarantees a single render per HTMX response + so toasts always make it to NetBox's afterSettle ``initMessages()`` hook. + """ + if response is None or not hasattr(response, "content"): + return response + if not isinstance(response.content, (bytes, bytearray)): + return response + try: + rendered = render_to_string("inc/messages.html", request=request) + except Exception: # pragma: no cover - defensive: don't break HTMX response on render error + logger.debug("Failed to render inc/messages.html for OOB toast attach", exc_info=True) + return response + response.content = response.content + rendered.encode("utf-8") + return response + + # Actions that require the force checkbox when a device-type mismatch is detected. _FORCE_REQUIRED_ACTIONS = frozenset({"link", "update", "update_serial", "update_type"}) @@ -136,18 +168,51 @@ def _htmx_error_response(message: str) -> HttpResponse: return resp -def _save_device(device) -> HttpResponse | None: - """Call full_clean() then save(). Return an HttpResponse on failure, None on success.""" +def _save_device(device, update_fields: list[str] | None = None, request=None) -> HttpResponse | None: + """Persist a Device row, returning an HttpResponse on failure or None on success. + + When ``update_fields`` is provided, the call uses ``save(update_fields=...)`` + which (a) issues a narrower UPDATE that only writes those columns and + (b) bypasses ``full_clean()``. This is the correct mode when the + caller mutates only a known small set of fields and the device row + may carry pre-existing inconsistencies on *other* fields (e.g. a + legacy ``face`` value left behind after a rack was cleared). + Validating those untouched fields would block legitimate updates. + + When ``update_fields`` is ``None`` (the default), the legacy behaviour + is preserved: ``full_clean()`` runs against the entire row before + ``save()`` writes every column. + + When ``request`` is provided and the request is an HTMX request, errors + are returned via ``_htmx_error_response()`` so modal swap/toast flows + remain intact. Otherwise plain ``HttpResponse`` status codes are returned. + """ + from django.db import IntegrityError + + def _err(msg: str, status: int) -> HttpResponse: + if request is not None and request.META.get("HTTP_HX_REQUEST"): + return _htmx_error_response(msg) + return HttpResponse(escape(msg), status=status) + + if update_fields is None: + try: + device.full_clean() + except ValidationError as exc: + error_msg = exc.message_dict if hasattr(exc, "message_dict") else str(exc) + return _err(f"Validation error: {error_msg}", 400) + try: + device.save() + except IntegrityError as exc: + return _err(f"Integrity error: {exc}", 409) + return None + try: - device.full_clean() + device.save(update_fields=update_fields) + except IntegrityError as exc: + return _err(f"Integrity error: {exc}", 409) except ValidationError as exc: error_msg = exc.message_dict if hasattr(exc, "message_dict") else str(exc) - return _htmx_error_response(f"Validation error: {error_msg}") - try: - device.save() - except IntegrityError: - logger.exception("Failed to save %s pk=%s", type(device).__name__, getattr(device, "pk", None)) - return _htmx_error_response("Unable to save changes. Please try again.") + return _err(f"Validation error: {error_msg}", 400) return None @@ -289,10 +354,13 @@ def render_device_row(self, request, libre_device: dict, validation: dict, selec "rack_id": selections["rack_id"], } - return render( + return _attach_messages_oob( + render( + request, + "netbox_librenms_plugin/htmx/device_import_row.html", + context, + ), request, - "netbox_librenms_plugin/htmx/device_import_row.html", - context, ) @@ -508,6 +576,19 @@ def post(self, request): "vc_detection_enabled": vc_detection_enabled, } + collisions = detect_bulk_collisions(devices) + if collisions: + # Render at 200 (not 4xx): this is an interstitial modal swapped + # into #htmx-modal-content, exactly like the confirm step. A non-2xx + # status makes HTMX skip the swap and route the body through + # htmx:responseError -> showErrorToast(), which would dump the + # collision template as raw text in a toast. + return render( + request, + "netbox_librenms_plugin/htmx/bulk_import_collision.html", + {"collisions": collisions, "device_count": len(devices)}, + ) + return render( request, "netbox_librenms_plugin/htmx/bulk_import_confirm.html", @@ -1200,9 +1281,11 @@ def post(self, request, device_id): hostname = _get_hostname_for_action(request, validation, libre_device) set_librenms_device_id(existing_device, librenms_id, self.librenms_api.server_key) existing_device.name = hostname + fields = ["custom_field_data", "name"] if librenms_device_type: existing_device.device_type = librenms_device_type - if err := _save_device(existing_device): + fields.append("device_type") + if err := _save_device(existing_device, update_fields=fields, request=request): return err logger.info(f"Linked device '{existing_device.name}' to LibreNMS ID {librenms_id}") @@ -1210,6 +1293,7 @@ def post(self, request, device_id): # Update hostname, serial, and link to LibreNMS hostname = _get_hostname_for_action(request, validation, libre_device) incoming_serial = libre_device.get("serial") or "" + fields = ["custom_field_data", "name"] if incoming_serial and incoming_serial != "-": # Lock any conflicting device under the same transaction to reduce # the serial-assignment race window (best-effort; a DB unique @@ -1226,11 +1310,13 @@ def post(self, request, device_id): f"'{conflict_device.name}' (ID: {conflict_device.pk})" ) existing_device.serial = incoming_serial + fields.append("serial") existing_device.name = hostname if librenms_device_type: existing_device.device_type = librenms_device_type + fields.append("device_type") set_librenms_device_id(existing_device, librenms_id, self.librenms_api.server_key) - if err := _save_device(existing_device): + if err := _save_device(existing_device, update_fields=fields, request=request): return err logger.info( f"Updated device '{existing_device.name}': serial={incoming_serial}, " @@ -1240,6 +1326,7 @@ def post(self, request, device_id): elif action == "update_serial": # Update only the serial and link to LibreNMS incoming_serial = libre_device.get("serial") or "" + fields = ["custom_field_data"] if incoming_serial and incoming_serial != "-": # Lock any conflicting device under the same transaction to reduce # the serial-assignment race window (best-effort; a DB unique @@ -1256,10 +1343,12 @@ def post(self, request, device_id): f"'{conflict_device.name}' (ID: {conflict_device.pk})" ) existing_device.serial = incoming_serial + fields.append("serial") if librenms_device_type: existing_device.device_type = librenms_device_type + fields.append("device_type") set_librenms_device_id(existing_device, librenms_id, self.librenms_api.server_key) - if err := _save_device(existing_device): + if err := _save_device(existing_device, update_fields=fields, request=request): return err logger.info( f"Updated serial on device '{existing_device.name}' to {incoming_serial}, " @@ -1270,7 +1359,7 @@ def post(self, request, device_id): # Sync device name from LibreNMS (e.g., IP → sysName) hostname = _get_hostname_for_action(request, validation, libre_device) existing_device.name = hostname - if err := _save_device(existing_device): + if err := _save_device(existing_device, update_fields=["name"], request=request): return err logger.info(f"Synced name on device '{existing_device.name}' from LibreNMS") @@ -1278,7 +1367,7 @@ def post(self, request, device_id): # Update device type from LibreNMS (requires force for mismatch) if librenms_device_type: existing_device.device_type = librenms_device_type - if err := _save_device(existing_device): + if err := _save_device(existing_device, update_fields=["device_type"], request=request): return err logger.info(f"Updated device type on '{existing_device.name}' to {librenms_device_type}") else: @@ -1313,7 +1402,7 @@ def post(self, request, device_id): f"'{conflict_device.name}' (ID: {conflict_device.pk})" ) locked_device.serial = incoming_serial - if err := _save_device(locked_device): + if err := _save_device(locked_device, update_fields=["serial"], request=request): return err logger.info(f"Synced serial on '{locked_device.name}' to {incoming_serial}") else: @@ -1328,7 +1417,7 @@ def post(self, request, device_id): match_result = find_matching_platform(librenms_os) if match_result["found"]: existing_device.platform = match_result["platform"] - if err := _save_device(existing_device): + if err := _save_device(existing_device, update_fields=["platform"], request=request): return err logger.info(f"Synced platform on '{existing_device.name}' to {match_result['platform']}") elif match_result.get("match_type") == "ambiguous": @@ -1353,7 +1442,7 @@ def post(self, request, device_id): hw_match = match_librenms_hardware_to_device_type(hardware) if hw_match and hw_match.get("matched"): existing_device.device_type = hw_match["device_type"] - if err := _save_device(existing_device): + if err := _save_device(existing_device, update_fields=["device_type"], request=request): return err logger.info(f"Synced device type on '{existing_device.name}' to {hw_match['device_type']}") else: @@ -1555,7 +1644,8 @@ def post(self, request, device_id): # Re-render the modal content as an OOB swap so it updates in place. # The inner views render via Django templates (auto-escaped), so the # decoded content is already safe HTML; wrap with format_html + mark_safe - # to compose the OOB envelope without introducing new escape boundaries. + # to compose the OOB envelope without introducing new escape boundaries + # (CodeQL trust-assertion pattern, see plugin docs). detail_view = DeviceValidationDetailsView() detail_view._librenms_api = self._librenms_api modal_html = detail_view.get(request, device_id).content.decode("utf-8") @@ -1806,6 +1896,601 @@ def post(self, request, device_id): return HttpResponse(oob_modal + row_html, content_type="text/html") +class AddAsOOBView( + LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View +): + """HTMX view to link a LibreNMS OOB controller device to an existing NetBox Device.""" + + def post(self, request, device_id): + """Attach a LibreNMS OOB identity to the matched NetBox device.""" + if error := self.require_write_permission(): + return error + + from dcim.models import Device + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + existing_device_id = request.POST.get("existing_device_id") + if not existing_device_id: + return _htmx_error_response("Missing existing_device_id") + + post_server_key = (request.POST.get("server_key") or "").strip() + if post_server_key: + self._librenms_api = LibreNMSAPI(server_key=post_server_key) + + try: + existing_device = Device.objects.get(pk=int(existing_device_id)) + except (Device.DoesNotExist, ValueError): + return _htmx_error_response("Existing device not found") + + self.required_object_permissions = {"POST": [("change", Device)]} + if error := self.require_object_permissions("POST"): + return error + + libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request) + if not libre_device: + return _htmx_error_response("LibreNMS device not found") + + oob_candidate = validation.get("oob_candidate") if validation else None + if not oob_candidate: + return _htmx_error_response("No OOB candidate found in validation data") + if oob_candidate["device"].pk != existing_device.pk: + return _htmx_error_response("Device ID mismatch: existing_device_id does not match OOB candidate") + + librenms_id = libre_device.get("device_id") + if isinstance(librenms_id, bool): + return _htmx_error_response("Invalid or missing LibreNMS device_id") + try: + librenms_id = int(librenms_id) + except (TypeError, ValueError): + return _htmx_error_response("Invalid or missing LibreNMS device_id") + if librenms_id <= 0: + return _htmx_error_response("Invalid LibreNMS device_id") + + # Reject legacy bare-int librenms_id (same guard as DeviceConflictActionView). + stored_id = existing_device.custom_field_data.get("librenms_id") + _is_legacy = isinstance(stored_id, int) and not isinstance(stored_id, bool) + if not _is_legacy and isinstance(stored_id, str): + try: + int(stored_id) + _is_legacy = True + except (ValueError, TypeError): + pass + if _is_legacy: + return _htmx_error_response( + "Device has a legacy bare-integer librenms_id; use 'Convert mapping' to migrate first." + ) + + from netbox_librenms_plugin.utils import set_librenms_oob + + oob_type = oob_candidate.get("type") or "" + oob_version = oob_candidate.get("version") or None + oob_ip_str = oob_candidate.get("ip") or None + server_key = self.librenms_api.server_key + + with transaction.atomic(): + try: + existing_device = Device.objects.select_for_update().get(pk=existing_device.pk) + except Device.DoesNotExist: + return _htmx_error_response("Device no longer exists; it may have been deleted concurrently.") + + from netbox_librenms_plugin.utils import coerce_librenms_id, get_librenms_oob + + current_oob = get_librenms_oob(existing_device, server_key=server_key) + if current_oob and coerce_librenms_id(current_oob.get("id")) != coerce_librenms_id(librenms_id): + return _htmx_error_response("OOB link was modified concurrently; refresh and retry.") + + try: + set_librenms_oob( + existing_device, + librenms_id, + server_key, + oob_type=oob_type, + version=oob_version, + ip=oob_ip_str, + ) + except ValueError as exc: + return _htmx_error_response(f"Invalid OOB data: {escape(str(exc))}") + + update_fields = ["custom_field_data"] + # Assign device.oob_ip if not already set; auto-create the IPAM + # record if it doesn't exist yet so the user has something to + # later attach to an interface and re-home if needed. + if oob_ip_str and existing_device.oob_ip_id is None: + oob_ip, oob_ip_created = get_or_create_global_ip( + oob_ip_str, auto_create=resolve_auto_create_ipam(request) + ) + if oob_ip is not None: + existing_device.oob_ip = oob_ip + update_fields.append("oob_ip") + if oob_ip_created: + messages.info( + request, + f"Auto-created OOB IP {oob_ip_str} in IPAM (unassigned, global scope).", + ) + + if err := _save_device(existing_device, update_fields=update_fields, request=request): + return err + + logger.info( + "Linked OOB device (LibreNMS ID %d, type %s) to '%s' (server: %s)", + librenms_id, + oob_type, + existing_device.name, + server_key, + ) + + cache_key = get_import_device_cache_key(device_id, server_key) + cache.delete(cache_key) + + libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request) + if not libre_device: + return _htmx_error_response("Device not found after action") + + response = self.render_device_row(request, libre_device, validation, selections) + # Keep the validation modal open and refresh its contents in place so + # the user can confirm the new OOB attachment without losing context. + response["HX-Trigger"] = json.dumps({"validationRefresh": {"deviceId": device_id}}) + return response + + +class PromoteToHostView( + LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View +): + """ + Promote an incoming LibreNMS host device to be the *primary* link of an existing + NetBox device whose current LibreNMS link is the OOB controller. + + The existing NetBox device's current ``librenms_id.{server_key}.id`` is moved into + the ``oob`` slot (preserving its bare-int → dict-form transition), and the incoming + LibreNMS device id becomes the new host id. No new NetBox device is created — this + is a reassignment, not an import. + """ + + def post(self, request, device_id): + if error := self.require_write_permission(): + return error + + from dcim.models import Device + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + existing_device_id = request.POST.get("existing_device_id") + if not existing_device_id: + return _htmx_error_response("Missing existing_device_id") + + post_server_key = (request.POST.get("server_key") or "").strip() + if post_server_key: + self._librenms_api = LibreNMSAPI(server_key=post_server_key) + + try: + existing_device = Device.objects.get(pk=int(existing_device_id)) + except (Device.DoesNotExist, ValueError): + return _htmx_error_response("Existing device not found") + + self.required_object_permissions = {"POST": [("change", Device)]} + if error := self.require_object_permissions("POST"): + return error + + # Optional per-field overrides from the pre-promote pick modal. + # All three default to "keep current"; only applied when the POST + # carries an explicit non-empty value. + override_name = (request.POST.get("override_name") or "").strip() or None + override_dt_id = (request.POST.get("override_device_type_id") or "").strip() or None + override_platform_id = (request.POST.get("override_platform_id") or "").strip() or None + + override_device_type = None + if override_dt_id: + from dcim.models import DeviceType + + try: + override_device_type = DeviceType.objects.get(pk=int(override_dt_id)) + except (DeviceType.DoesNotExist, ValueError, TypeError): + return _htmx_error_response("Invalid override_device_type_id") + + override_platform = None + if override_platform_id: + from dcim.models import Platform + + try: + override_platform = Platform.objects.get(pk=int(override_platform_id)) + except (Platform.DoesNotExist, ValueError, TypeError): + return _htmx_error_response("Invalid override_platform_id") + + libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request) + if not libre_device: + return _htmx_error_response("LibreNMS device not found") + + promote = validation.get("promote_to_host") if validation else None + if not promote: + return _htmx_error_response("Promotion is not applicable for this device") + validated_existing = validation.get("existing_device") + if validated_existing is None: + return _htmx_error_response("Missing validated conflict target for promotion") + if validated_existing.pk != existing_device.pk: + return _htmx_error_response("Device ID mismatch: existing_device_id does not match validation result") + + new_host_id = libre_device.get("device_id") + if isinstance(new_host_id, bool): + return _htmx_error_response("Invalid or missing LibreNMS device_id") + try: + new_host_id = int(new_host_id) + except (TypeError, ValueError): + return _htmx_error_response("Invalid or missing LibreNMS device_id") + if new_host_id <= 0: + return _htmx_error_response("Invalid LibreNMS device_id") + + existing_libre_id = promote.get("existing_libre_id") + try: + existing_libre_id = int(existing_libre_id) + except (TypeError, ValueError): + return _htmx_error_response("Invalid existing LibreNMS id in promotion data") + if existing_libre_id == new_host_id: + return _htmx_error_response("Existing link already points at this LibreNMS device") + + oob_type = promote.get("existing_oob_type") or "" + if not oob_type: + return _htmx_error_response("Cannot determine OOB type for promotion") + + from netbox_librenms_plugin.utils import set_librenms_device_id, set_librenms_oob + + server_key = self.librenms_api.server_key + + # Pull stored OOB metadata (ip/version) from existing librenms link if possible, + # then ensure the incoming device's IP populates oob_ip on the existing device + # only if oob_ip isn't already set. + existing_oob_ip = None + oob_ip_str = None + cf_value = existing_device.custom_field_data.get("librenms_id") + if isinstance(cf_value, dict): + entry = cf_value.get(server_key) + if isinstance(entry, dict): + existing_oob_dict = entry.get("oob") if isinstance(entry.get("oob"), dict) else {} + existing_oob_ip = existing_oob_dict.get("ip") if isinstance(existing_oob_dict, dict) else None + # Prefer existing OOB ip if it was already known; otherwise use existing device's + # current oob_ip relationship; otherwise leave unset (do NOT inherit incoming + # device's IP — that IP belongs to the host, not the OOB controller). + if not existing_oob_ip and existing_device.oob_ip_id: + try: + from ipam.models import IPAddress # noqa + + existing_oob_ip = str(existing_device.oob_ip).split("/")[0] if existing_device.oob_ip else None + except Exception: # pragma: no cover - defensive + existing_oob_ip = None + oob_ip_str = existing_oob_ip or None + + # Reject legacy bare-int librenms_id form (caller should migrate first). + stored_id = existing_device.custom_field_data.get("librenms_id") + _is_legacy = isinstance(stored_id, int) and not isinstance(stored_id, bool) + if not _is_legacy and isinstance(stored_id, str): + try: + int(stored_id) + _is_legacy = True + except (ValueError, TypeError): + pass + if _is_legacy: + return _htmx_error_response( + "Device has a legacy bare-integer librenms_id; use 'Convert mapping' to migrate first." + ) + + # Fetch OOB IP from LibreNMS outside the transaction so the row lock + # is not held during a potentially slow network round-trip. + _pre_fetched_oob_ip = None + if not oob_ip_str: + try: + ok, oob_info = self.librenms_api.get_device_info(existing_libre_id) + except Exception: # pragma: no cover - defensive + ok, oob_info = False, None + if ok and isinstance(oob_info, dict): + _fetched = (oob_info.get("ip") or "").strip() or None + if _fetched: + _pre_fetched_oob_ip = _fetched + + with transaction.atomic(): + try: + existing_device = Device.objects.select_for_update().get(pk=existing_device.pk) + except Device.DoesNotExist: + return _htmx_error_response("Device no longer exists; it may have been deleted concurrently.") + + from netbox_librenms_plugin.utils import coerce_librenms_id, get_librenms_device_id, get_librenms_oob + + current_host_id = get_librenms_device_id(existing_device, server_key=server_key, auto_save=False) + current_oob = get_librenms_oob(existing_device, server_key=server_key) + if coerce_librenms_id(current_host_id) != coerce_librenms_id(existing_libre_id): + return _htmx_error_response("LibreNMS host link changed concurrently; refresh and retry.") + if current_oob: + return _htmx_error_response( + "OOB link already set; this device may have been promoted by a concurrent request." + ) + + try: + # First, swap the host id to the incoming LibreNMS device id. + # set_librenms_device_id preserves any existing OOB sub-object. + set_librenms_device_id( + existing_device, + new_host_id, + server_key=server_key, + ) + # Then attach the previously-linked LibreNMS id as the OOB controller. + set_librenms_oob( + existing_device, + existing_libre_id, + server_key, + oob_type=oob_type, + ip=oob_ip_str, + ) + except ValueError as exc: + return _htmx_error_response(f"Invalid promotion data: {escape(str(exc))}") + + # After promotion, populate IP relationships on the existing device: + # - primary_ip4 / primary_ip6 from the incoming LibreNMS host's IP + # (the device that's now linked as the host) + # - oob_ip from the previously-linked LibreNMS device's IP + # (the device that's now demoted into the OOB slot) + # If the IP does not yet exist in NetBox we create a global /32 (or + # /128) entry so the device row looks complete after promotion; + # the user can re-home / mask it later via the IP-sync flow. + # Both writes are best-effort and never overwrite an already-set + # primary_ip4 / primary_ip6 / oob_ip relationship. + update_fields = ["custom_field_data"] + + host_ip_str = (libre_device.get("ip") or "").strip() or None + if host_ip_str: + _auto_create = resolve_auto_create_ipam(request) + host_ip, host_ip_created = get_or_create_global_ip(host_ip_str, auto_create=_auto_create) + if host_ip is not None: + try: + is_v6 = _ipaddr_parse(host_ip_str).version == 6 + except ValueError: + is_v6 = False + # Only set as primary if the IP is unassigned OR already + # assigned to an interface that belongs to this device. + # Setting a primary IP whose assigned_object belongs to a + # different device would create an inconsistent state. + ao = host_ip.assigned_object + ip_owned_by_device = ao is None or (hasattr(ao, "device_id") and ao.device_id == existing_device.pk) + assigned = False + if ip_owned_by_device: + if is_v6 and existing_device.primary_ip6_id is None: + existing_device.primary_ip6 = host_ip + update_fields.append("primary_ip6") + assigned = True + elif not is_v6 and existing_device.primary_ip4_id is None: + existing_device.primary_ip4 = host_ip + update_fields.append("primary_ip4") + assigned = True + else: + logger.warning( + "Skipping primary IP assignment for %s: IP %s is already assigned " + "to a different object (%r); user must assign it manually.", + existing_device, + host_ip_str, + ao, + ) + if host_ip_created and assigned: + messages.info( + request, + f"Auto-created primary IP {host_ip_str} in IPAM (unassigned, global scope).", + ) + + # Apply pre-fetched OOB IP if we got one and the row is still eligible. + if _pre_fetched_oob_ip and not oob_ip_str and existing_device.oob_ip_id is None: + oob_ip_str = _pre_fetched_oob_ip + # Cache the fetched IP in the OOB sub-object (CFD) while we hold the lock. + try: + set_librenms_oob( + existing_device, + existing_libre_id, + server_key, + oob_type=oob_type, + ip=oob_ip_str, + ) + except ValueError: # pragma: no cover - defensive + pass + + if oob_ip_str and existing_device.oob_ip_id is None: + oob_ip, oob_ip_created = get_or_create_global_ip( + oob_ip_str, auto_create=resolve_auto_create_ipam(request) + ) + if oob_ip is not None: + existing_device.oob_ip = oob_ip + update_fields.append("oob_ip") + if oob_ip_created: + messages.info( + request, + f"Auto-created OOB IP {oob_ip_str} in IPAM (unassigned, global scope).", + ) + + # Apply any explicit per-field overrides chosen in the pre-promote modal. + # Default behaviour (no overrides) keeps the existing device's name, type + # and platform — matching the original promote semantics. + if override_name and override_name != existing_device.name: + existing_device.name = override_name + update_fields.append("name") + if override_device_type and existing_device.device_type_id != override_device_type.pk: + existing_device.device_type = override_device_type + update_fields.append("device_type") + if override_platform and existing_device.platform_id != override_platform.pk: + existing_device.platform = override_platform + update_fields.append("platform") + + if err := _save_device(existing_device, update_fields=update_fields, request=request): + return err + + logger.info( + "Promoted LibreNMS host (id %d) to '%s' on server %s; demoted previous link (id %d, type %s) to OOB slot", + new_host_id, + existing_device.name, + server_key, + existing_libre_id, + oob_type, + ) + + cache_key = get_import_device_cache_key(device_id, server_key) + cache.delete(cache_key) + + libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request) + if not libre_device: + return _htmx_error_response("Device not found after action") + + response = self.render_device_row(request, libre_device, validation, selections) + # Keep the underlying validation modal open and re-fetch its content so + # the user can see the device's new link state (host id + OOB slot) + # without losing context. The JS handler in librenms_import.js fires a + # fresh GET to the validation URL using the row's existing details + # button. + response["HX-Trigger"] = json.dumps({"validationRefresh": {"deviceId": device_id}}) + return response + + +class MergeNetBoxDevicesView( + LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View +): + """ + Merge two existing NetBox devices that represent the same physical box. + + The user (via radio buttons in the validation modal) picks which device is + the **winner** (kept) and which is the **donor** (absorbed). The donor's + LibreNMS link state under the active ``server_key`` is merged into the + winner; the donor's active link is then cleared and a ``_migrated_to`` + marker is written. Interfaces, cables and primary IPs are NOT moved — + those stay on the donor for the user to re-home incrementally via the + Stage-2b "Migrated to X" tab. + """ + + def post(self, request, device_id): + if error := self.require_write_permission(): + return error + + from dcim.models import Device + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + from netbox_librenms_plugin.utils import ( + mark_librenms_migrated, + merge_librenms_links, + ) + + post_server_key = (request.POST.get("server_key") or "").strip() + if post_server_key: + self._librenms_api = LibreNMSAPI(server_key=post_server_key) + + winner_pk_raw = request.POST.get("winner_pk") + donor_pk_raw = request.POST.get("donor_pk") + if not winner_pk_raw or not donor_pk_raw: + return _htmx_error_response("Missing winner_pk or donor_pk") + try: + winner_pk = int(winner_pk_raw) + donor_pk = int(donor_pk_raw) + except (TypeError, ValueError): + return _htmx_error_response("Invalid winner_pk or donor_pk") + if winner_pk == donor_pk: + return _htmx_error_response("Winner and donor must be different devices") + + try: + winner = Device.objects.get(pk=winner_pk) + donor = Device.objects.get(pk=donor_pk) + except Device.DoesNotExist: + return _htmx_error_response("Winner or donor device not found") + + # Permission gate: user must be able to change BOTH devices. + self.required_object_permissions = {"POST": [("change", Device)]} + if error := self.require_object_permissions("POST"): + return error + + libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request) + if not libre_device: + return _htmx_error_response("LibreNMS device not found") + + merge_candidates = (validation or {}).get("merge_candidates") or {} + candidate_pks = { + (merge_candidates.get("host_named") or {}).get("pk"), + (merge_candidates.get("oob_named") or {}).get("pk"), + } + candidate_pks.discard(None) + if {winner_pk, donor_pk} != candidate_pks: + return _htmx_error_response("winner_pk/donor_pk do not match the validation result's merge candidates") + + # Reject legacy bare-int librenms_id form on either side. The merge + # helpers refuse to operate on legacy data to prevent silent migration. + for label, obj in (("winner", winner), ("donor", donor)): + stored = obj.custom_field_data.get("librenms_id") + is_legacy = isinstance(stored, int) and not isinstance(stored, bool) + if not is_legacy and isinstance(stored, str): + try: + int(stored) + is_legacy = True + except (ValueError, TypeError): + pass + if is_legacy: + return _htmx_error_response( + f"{label.capitalize()} device has a legacy bare-integer librenms_id; " + "use 'Convert mapping' to migrate before merging." + ) + + server_key = self.librenms_api.server_key + + with transaction.atomic(): + # Lock both rows in deterministic pk order to avoid deadlocks. + locked = list(Device.objects.select_for_update().filter(pk__in=[winner_pk, donor_pk]).order_by("pk")) + if len(locked) != 2: + return _htmx_error_response( + "One of the devices no longer exists; it may have been deleted concurrently." + ) + locked_by_pk = {d.pk: d for d in locked} + winner = locked_by_pk[winner_pk] + donor = locked_by_pk[donor_pk] + + try: + summary = merge_librenms_links(winner, donor, server_key=server_key) + except ValueError as exc: + return _htmx_error_response(f"Cannot merge: {escape(str(exc))}") + + # Transfer OOB IP relationship if winner has none and donor has one. + oob_ip_transferred = False + if donor.oob_ip_id and not winner.oob_ip_id: + winner.oob_ip = donor.oob_ip + donor.oob_ip = None + oob_ip_transferred = True + + # Clear donor's active link and stamp migration marker. + mark_librenms_migrated(donor, winner.pk, server_key=server_key) + + # Persist only the fields we actually touched. Calling + # ``full_clean()`` here (or relying on it via ``_save_device``) + # would re-validate every field on the device — which is + # undesirable when the rows hold pre-existing inconsistencies + # (e.g. ``face`` set without ``rack``) that are unrelated to + # this merge. See issue surfaced during eve-ng-02 merge. + update_fields = ["custom_field_data"] + if oob_ip_transferred: + update_fields.append("oob_ip") + try: + winner.save(update_fields=update_fields) + donor.save(update_fields=update_fields) + except Exception as exc: # pragma: no cover - defensive + transaction.set_rollback(True) + return _htmx_error_response(f"Save failed: {escape(str(exc))}") + + logger.info( + "Merged NetBox device '%s' (pk=%d) into '%s' (pk=%d) on server %s. Summary: %s; oob_ip_transferred=%s", + donor.name, + donor.pk, + winner.name, + winner.pk, + server_key, + summary, + oob_ip_transferred, + ) + + cache_key = get_import_device_cache_key(device_id, server_key) + cache.delete(cache_key) + + libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request) + if not libre_device: + return _htmx_error_response("Device not found after merge") + + response = self.render_device_row(request, libre_device, validation, selections) + response["HX-Trigger"] = "closeModal" + return response + + class SaveUserPrefView(LibreNMSPermissionMixin, View): """Save a user preference via POST. Used by JS toggle handlers.""" diff --git a/netbox_librenms_plugin/views/imports/list.py b/netbox_librenms_plugin/views/imports/list.py index d2459a439..b155611fa 100644 --- a/netbox_librenms_plugin/views/imports/list.py +++ b/netbox_librenms_plugin/views/imports/list.py @@ -62,7 +62,7 @@ def should_use_background_job(self): return False return self._filter_form_data.get("use_background_job", True) - def _load_job_results(self, job_id): + def _load_job_results(self, job_id, request=None): """ Load cached results from a completed background job. @@ -128,6 +128,10 @@ def _load_job_results(self, job_id): # Mirror the job's naming settings so toggle state matches the cached results self._use_sysname = use_sysname self._strip_domain = strip_domain + # IPAM auto-create toggle: honour the request-scoped POST/GET override + # first (set by the import page toggle), then fall through to saved pref + # and plugin settings — matches the cascade used by resolve_auto_create_ipam(). + self._auto_create_ipam = resolve_auto_create_ipam(request) return validated_devices @@ -158,6 +162,7 @@ def get(self, request, *args, **kwargs): # noqa: D401 - inherited doc _use_sysname = get_user_pref(request, "plugins.netbox_librenms_plugin.use_sysname") _strip_domain = get_user_pref(request, "plugins.netbox_librenms_plugin.strip_domain") + _auto_create_ipam = get_user_pref(request, "plugins.netbox_librenms_plugin.auto_create_ipam") if _use_sysname is None: _use_sysname = getattr(settings_obj, "use_sysname_default", True) if settings_obj else True if _strip_domain is None: @@ -186,7 +191,7 @@ def get(self, request, *args, **kwargs): # noqa: D401 - inherited doc try: job_id = int(job_id) logger.info(f"Loading results from job {job_id}") - validated_devices = self._load_job_results(job_id) + validated_devices = self._load_job_results(job_id, request=request) if validated_devices: self._import_data = validated_devices self._job_results_loaded = True diff --git a/netbox_librenms_plugin/views/sync/migrate.py b/netbox_librenms_plugin/views/sync/migrate.py new file mode 100644 index 000000000..249893575 --- /dev/null +++ b/netbox_librenms_plugin/views/sync/migrate.py @@ -0,0 +1,383 @@ +""" +Stage 2b: per-row "Move to winner" actions on a donor device whose +``librenms_id[server_key]`` carries a ``_migrated_to`` marker. + +Each view validates the marker, looks up the winner Device, runs an +atomic move under ``select_for_update`` ordering by primary key (to avoid +cross-merge deadlocks), and returns either an HTMX-friendly partial +response or a plain redirect. + +Permissions: each view requires plugin write permission AND the +appropriate NetBox model permission on the object being moved. +""" + +from dcim.models import Device, Interface +from django.contrib import messages +from django.db import transaction +from django.http import HttpResponse +from django.utils.html import format_html +from django.shortcuts import get_object_or_404, redirect +from django.utils.http import url_has_allowed_host_and_scheme +from django.views import View +from ipam.models import IPAddress + +from netbox_librenms_plugin.utils import get_migrated_to_marker +from netbox_librenms_plugin.views.mixins import ( + LibreNMSPermissionMixin, + NetBoxObjectPermissionMixin, +) + + +def _resolve_winner_for_donor(donor, server_key="default"): + """ + Return ``(winner, marker)`` for *donor*. + + - ``(None, None)`` when no ``_migrated_to`` marker is present. + - ``(None, marker)`` when the marker exists but the winner device has + been deleted or the ``device_id`` in the marker is invalid/unparseable + (so callers can distinguish "no marker" from "stale marker"). + - ``(winner, marker)`` when the marker is valid and the winner exists. + + ``marker`` is the dict written by :func:`mark_librenms_migrated`. + """ + marker = get_migrated_to_marker(donor, server_key) + if not marker: + return None, None + try: + winner_pk = int(marker.get("device_id")) + except (TypeError, ValueError): + return None, marker + winner = Device.objects.filter(pk=winner_pk).first() + if winner is None: + return None, marker + return winner, marker + + +def _server_key_from_request(request, default_server_key=None): + """Extract the LibreNMS server key from the POST body (form field). + + Pass ``default_server_key=self.librenms_api.server_key`` from views that + have API access so the fallback matches the active server's namespace. + When no default is given, ``"default"`` is used as a last-resort fallback + (migrate views always receive the correct key via the POST body). + """ + sk = request.POST.get("server_key") or default_server_key + return sk if isinstance(sk, str) and sk else (default_server_key or "default") + + +def _safe_referer(request): + """ + Return the request's ``Referer`` only when it points back at this + site, otherwise ``"/"``. + + ``Referer`` is a client-controlled header, so it must be validated + against the current host before being used as a redirect target — + trusting it blindly is an open-redirect vector. + """ + referer = request.META.get("HTTP_REFERER") + if referer and url_has_allowed_host_and_scheme( + referer, + allowed_hosts={request.get_host()}, + require_https=request.is_secure(), + ): + return referer + return "/" + + +def _hx_response(request, message, level=messages.SUCCESS, *, status=200): + """ + Common HTMX response: queue a Django messages flash and emit the + ``HX-Refresh`` header so the sync page re-renders with the row gone. + + For non-HTMX requests, queue the message and redirect to the + validated Referer or '/'. + """ + messages.add_message(request, level, message) + if request.headers.get("HX-Request"): + return HttpResponse(status=status, headers={"HX-Refresh": "true"}) + return redirect(_safe_referer(request)) + + +class _BaseMoveToWinnerView(LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, View): + """Shared plumbing for the per-resource move endpoints.""" + + def _gate(self, request): + """ + Plugin-write + object-perm gate. Returns a response on failure + (which the caller must return verbatim) or ``None`` on success. + """ + resp = self.require_all_permissions("POST") + if resp is not None: + return resp + return None + + def _fail(self, request, msg, *, status=400): + """ + Return an error response. + + For HTMX requests: returns HTTP 200 with an out-of-band swap into + ``#django-messages`` so the toast renders through NetBox's Bootstrap + pipeline. ``HX-Reswap: none`` prevents the primary swap target from + being overwritten. The ``status`` parameter is not used for HTMX + responses — errors are always signalled via the OOB toast, not the + status code. + + For non-HTMX requests: adds a Django error message and redirects to + the validated Referer or '/'. + """ + if request.headers.get("HX-Request"): + toast_html = format_html( + '
' + '
", + msg, + ) + resp = HttpResponse(toast_html, content_type="text/html") + resp["HX-Reswap"] = "none" + return resp + messages.error(request, msg) + return redirect(_safe_referer(request)) + + +class MoveInterfaceToWinnerView(_BaseMoveToWinnerView): + """ + Reassign ``Interface.device`` from donor to winner. + + Cables, IP-address attachments, MAC objects, and VLAN tag config all + point at the Interface row by FK, so they follow the move + automatically. + + Fails (toast + 409) when the winner already has an interface with the + same name — the user must rename or delete the colliding interface + on the winner first. + """ + + required_object_permissions = {"POST": [("change", Interface)]} + + def post(self, request, pk): + gate = self._gate(request) + if gate is not None: + return gate + + interface = get_object_or_404(Interface, pk=pk) + donor = interface.device + if donor is None: + return self._fail(request, "Interface has no device.") + + server_key = _server_key_from_request(request) + winner, marker = _resolve_winner_for_donor(donor, server_key) + if marker is None: + return self._fail(request, "Donor device is not marked as migrated.", status=409) + if winner is None: + return self._fail(request, "Winner device no longer exists.", status=410) + + # Quick pre-check before acquiring the lock (avoids round-trip in the + # obvious already-taken case). The check is repeated under the lock + # below to close the TOCTOU window. + if Interface.objects.filter(device=winner, name=interface.name).exists(): + return self._fail( + request, + f"Winner device '{winner.name}' already has an interface named '{interface.name}'. " + "Rename or remove the existing interface first.", + status=409, + ) + + with transaction.atomic(): + # Lock both devices in pk order to avoid cross-merge deadlocks. + ordered = sorted({donor.pk, winner.pk}) + list(Device.objects.select_for_update().filter(pk__in=ordered).order_by("pk")) + # Re-check under the lock to close the TOCTOU window. + if Interface.objects.filter(device=winner, name=interface.name).exists(): + return self._fail( + request, + f"Winner device '{winner.name}' already has an interface named '{interface.name}'. " + "Rename or remove the existing interface first.", + status=409, + ) + # Use a conditional update so only device_id is changed and the row + # is only moved if it still belongs to the donor (guards against a + # concurrent reassignment between the pre-lock check and now). + updated = Interface.objects.filter(pk=interface.pk, device=donor).update(device=winner) + if updated == 0: + return self._fail( + request, + f"Interface '{interface.name}' is no longer attached to '{donor.name}'.", + status=409, + ) + + return _hx_response( + request, + f"Moved interface '{interface.name}' to {winner.name}.", + ) + + +class MoveIPAddressToWinnerView(_BaseMoveToWinnerView): + """ + Reassign ``IPAddress.assigned_object`` from a donor-owned target to + the winner's equivalent. + + Behaviour by current assignment: + + * Assigned to a donor :class:`Interface` whose name exists on the + winner → reassign to the winner's same-name interface. + * Assigned to a donor :class:`Interface` whose name does *not* exist + on the winner → fail (user must move the interface first). + * Unassigned IP picked from the donor's sync page → fail (no donor + relationship to migrate). + """ + + required_object_permissions = {"POST": [("change", IPAddress)]} + + def post(self, request, pk): + gate = self._gate(request) + if gate is not None: + return gate + + ip = get_object_or_404(IPAddress, pk=pk) + assigned = ip.assigned_object + if not isinstance(assigned, Interface): + return self._fail( + request, + "IP is not assigned to a donor interface; nothing to migrate.", + status=409, + ) + donor = assigned.device + if donor is None: + return self._fail(request, "IP's interface has no device.", status=409) + + server_key = _server_key_from_request(request) + winner, marker = _resolve_winner_for_donor(donor, server_key) + if marker is None: + return self._fail(request, "Donor device is not marked as migrated.", status=409) + if winner is None: + return self._fail(request, "Winner device no longer exists.", status=410) + + # Quick pre-check before acquiring the lock. Repeated under lock below. + if not Interface.objects.filter(device=winner, name=assigned.name).exists(): + return self._fail( + request, + f"Winner '{winner.name}' has no interface named '{assigned.name}'. " + "Move the interface first, then retry.", + status=409, + ) + + with transaction.atomic(): + ordered = sorted({donor.pk, winner.pk}) + locked = {d.pk: d for d in Device.objects.select_for_update().filter(pk__in=ordered).order_by("pk")} + donor = locked.get(donor.pk) + winner = locked.get(winner.pk) + if donor is None or winner is None: + return self._fail(request, "Device was deleted concurrently.", status=410) + ip = IPAddress.objects.select_for_update().filter(pk=ip.pk).first() + if ip is None: + return self._fail(request, "IP address no longer exists.", status=410) + assigned = ip.assigned_object + if not isinstance(assigned, Interface) or assigned.device_id != donor.pk: + return self._fail(request, "IP is no longer assigned to the donor interface.", status=409) + # Re-fetch the winner interface under the lock to close the TOCTOU window. + winner_iface = Interface.objects.select_for_update().filter(device=winner, name=assigned.name).first() + if winner_iface is None: + return self._fail( + request, + f"Winner '{winner.name}' has no interface named '{assigned.name}'. " + "Move the interface first, then retry.", + status=409, + ) + ip.assigned_object = winner_iface + ip.save(update_fields=["assigned_object_type", "assigned_object_id"]) + + return _hx_response( + request, + f"Moved IP {ip.address} to {winner.name} interface '{winner_iface.name}'.", + ) + + +class TransferDeviceIPView(_BaseMoveToWinnerView): + """ + One-shot transfer of a donor's primary IPv4/v6 or OOB IP to the + winner. + + Triggered with URL kwarg ``ip_kind`` ∈ ``{"primary4", "primary6", + "oob"}``. Refuses to overwrite a value already set on the winner — + the user must clear it on the winner first. + + The IPAddress object itself is *not* moved off its current + ``assigned_object`` (an interface) — only the ``Device.primary_ip4`` + / ``primary_ip6`` / ``oob_ip`` foreign key on the winner is set, and + the donor's foreign key is cleared. + """ + + required_object_permissions = {"POST": [("change", Device)]} + + _FIELD_MAP = { + "primary4": ("primary_ip4", "primary IPv4"), + "primary6": ("primary_ip6", "primary IPv6"), + "oob": ("oob_ip", "OOB IP"), + } + + def post(self, request, pk, ip_kind): + gate = self._gate(request) + if gate is not None: + return gate + + if ip_kind not in self._FIELD_MAP: + return self._fail(request, f"Unknown ip_kind '{ip_kind}'.") + field, human = self._FIELD_MAP[ip_kind] + + donor = get_object_or_404(Device, pk=pk) + server_key = _server_key_from_request(request) + winner, marker = _resolve_winner_for_donor(donor, server_key) + if marker is None: + return self._fail(request, "Donor device is not marked as migrated.", status=409) + if winner is None: + return self._fail(request, "Winner device no longer exists.", status=410) + + donor_ip = getattr(donor, field, None) + if donor_ip is None: + return self._fail(request, f"Donor has no {human} to transfer.", status=409) + # Quick pre-check before acquiring the lock. Repeated under lock below. + if getattr(winner, field, None) is not None: + return self._fail( + request, + f"Winner '{winner.name}' already has a {human}. Clear it on the winner first.", + status=409, + ) + + with transaction.atomic(): + ordered = sorted({donor.pk, winner.pk}) + locked = {d.pk: d for d in Device.objects.select_for_update().filter(pk__in=ordered).order_by("pk")} + donor = locked.get(donor.pk) + winner = locked.get(winner.pk) + if donor is None or winner is None: + return self._fail(request, "Device was deleted concurrently.", status=410) + # Re-check under the lock so concurrent transfers don't race. + donor_ip = getattr(donor, field, None) + if donor_ip is None: + return self._fail(request, f"Donor has no {human} to transfer.", status=409) + if getattr(winner, field, None) is not None: + return self._fail( + request, + f"Winner '{winner.name}' already has a {human}. Clear it on the winner first.", + status=409, + ) + setattr(winner, field, donor_ip) + setattr(donor, field, None) + # Save only the touched FK column to avoid full_clean() rejecting + # the merge over pre-existing inconsistencies on either device + # (e.g. ``face`` set without ``rack``). + winner.save(update_fields=[field]) + donor.save(update_fields=[field]) + + return _hx_response( + request, + f"Transferred {human} ({donor_ip}) to {winner.name}.", + ) From 9812d0c594f4e403c67e4e7c1562e469d9c5b2d5 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Sun, 31 May 2026 18:46:24 +0200 Subject: [PATCH 02/98] feat(oob-sync): set OOB IP via an interface-assigned address; drop generic IPAM Rework OOB IP handling to attach the address to a chosen device interface (forced-interface flow) so it satisfies NetBox's primary/oob_ip constraint, replacing the generic auto-create-IPAM path. Store only id+type in the librenms_id OOB sub-object. --- .../import_utils/__init__.py | 1 - .../htmx/_oob_interface_select.html | 28 ++ .../htmx/device_validation_details.html | 24 +- .../tests/test_coverage_actions.py | 129 +++++++++ .../tests/test_librenms_id.py | 11 +- netbox_librenms_plugin/utils.py | 13 +- .../views/imports/actions.py | 247 ++++++++---------- netbox_librenms_plugin/views/imports/list.py | 5 - 8 files changed, 297 insertions(+), 161 deletions(-) create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_oob_interface_select.html diff --git a/netbox_librenms_plugin/import_utils/__init__.py b/netbox_librenms_plugin/import_utils/__init__.py index 385c7ff40..1028a7f0a 100644 --- a/netbox_librenms_plugin/import_utils/__init__.py +++ b/netbox_librenms_plugin/import_utils/__init__.py @@ -33,7 +33,6 @@ import_single_device, validate_device_for_import, ) -from .ip_helpers import auto_create_ipam_enabled, get_or_create_global_ip # noqa: F401 from .collisions import detect_bulk_collisions # noqa: F401 from .filters import ( # noqa: F401 _apply_client_filters, diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_oob_interface_select.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_oob_interface_select.html new file mode 100644 index 000000000..85d10db60 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_oob_interface_select.html @@ -0,0 +1,28 @@ +{% comment %} +OOB IP interface picker for the "Add as OOB" form. + +NetBox requires oob_ip to be assigned to one of the device's interfaces, so the +user picks (or creates) the interface to hang the OOB IP on. The suggested +interface (idrac/ilo/bmc-style by name) is pre-selected; the OOB IP is often not +physically on the matched interface, so this is overridable. + +Context: libre_device, validation, oob_interfaces, oob_suggested_interface_id, +oob_default_new_name. +{% endcomment %} +
+ + + + {% if validation.oob_candidate.ip %} + OOB IP {{ validation.oob_candidate.ip }} is assigned to this interface and set as the device's OOB IP. + {% endif %} +
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 6be5ef18c..3a125ab34 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 @@ -116,7 +116,7 @@
+ hx-include="#use-sysname-toggle, #strip-domain-toggle"> {% csrf_token %} @@ -165,7 +165,7 @@
+ hx-include="#use-sysname-toggle, #strip-domain-toggle"> {% csrf_token %} @@ -572,7 +572,7 @@
+ hx-include="#use-sysname-toggle, #strip-domain-toggle"> {% csrf_token %} @@ -690,13 +690,12 @@
- + {% csrf_token %} + {% include "netbox_librenms_plugin/htmx/_oob_interface_select.html" %} @@ -755,7 +754,7 @@
- + {% csrf_token %} + {% include "netbox_librenms_plugin/htmx/_oob_interface_select.html" %} diff --git a/netbox_librenms_plugin/tests/test_coverage_actions.py b/netbox_librenms_plugin/tests/test_coverage_actions.py index c594ee6d5..19e655552 100644 --- a/netbox_librenms_plugin/tests/test_coverage_actions.py +++ b/netbox_librenms_plugin/tests/test_coverage_actions.py @@ -4466,3 +4466,132 @@ def test_libre_device_not_found_returns_htmx_error(self): assert response.status_code == 200 assert b"not found" in response.content.lower() + + +class TestSuggestOOBInterface: + """_suggest_oob_interface: pre-select an OOB/mgmt-named interface + default new name.""" + + def _iface(self, name): + i = MagicMock() + i.name = name + return i + + def test_picks_idrac_named_interface(self): + from netbox_librenms_plugin.views.imports.actions import _suggest_oob_interface + + dev = MagicMock() + eth, idrac = self._iface("eth0"), self._iface("iDRAC") + idrac.pk = 7 + dev.interfaces.all.return_value = [eth, idrac] + sid, new_name = _suggest_oob_interface(dev, {"type": "idrac"}) + assert sid == 7 + assert new_name == "idrac0" + + def test_no_match_returns_none_and_typed_default(self): + from netbox_librenms_plugin.views.imports.actions import _suggest_oob_interface + + dev = MagicMock() + eth = self._iface("eth0") + dev.interfaces.all.return_value = [eth] + sid, new_name = _suggest_oob_interface(dev, {"type": "ilo"}) + assert sid is None + assert new_name == "ilo0" + + def test_missing_type_defaults_to_oob(self): + from netbox_librenms_plugin.views.imports.actions import _suggest_oob_interface + + dev = MagicMock() + dev.interfaces.all.return_value = [] + sid, new_name = _suggest_oob_interface(dev, {}) + assert sid is None + assert new_name == "oob0" + + +class TestResolveOOBInterface: + """AddAsOOBView._resolve_oob_interface: select existing / create new / none.""" + + def _view(self): + from netbox_librenms_plugin.views.imports.actions import AddAsOOBView + + return object.__new__(AddAsOOBView) + + def test_none_when_no_selection(self): + view = self._view() + req = _make_request(post={}) + assert view._resolve_oob_interface(req, MagicMock()) is None + + def test_existing_interface_by_id(self): + view = self._view() + req = _make_request(post={"oob_interface_id": "5"}) + dev = MagicMock() + with patch("dcim.models.Interface") as mock_iface_cls: + mock_iface_cls.objects.get.return_value = MagicMock(name="eth0") + result = view._resolve_oob_interface(req, dev) + mock_iface_cls.objects.get.assert_called_once_with(pk=5, device=dev) + assert result is mock_iface_cls.objects.get.return_value + + def test_create_new_interface(self): + view = self._view() + req = _make_request(post={"oob_interface_id": "__new__", "oob_new_interface_name": "idrac0"}) + dev = MagicMock() + with patch("dcim.models.Interface") as mock_iface_cls: + mock_iface_cls.objects.get_or_create.return_value = (MagicMock(), True) + view._resolve_oob_interface(req, dev) + mock_iface_cls.objects.get_or_create.assert_called_once_with( + device=dev, name="idrac0", defaults={"type": "other"} + ) + + def test_new_without_name_returns_none(self): + view = self._view() + req = _make_request(post={"oob_interface_id": "__new__", "oob_new_interface_name": ""}) + assert view._resolve_oob_interface(req, MagicMock()) is None + + +class TestAttachOOBIp: + """AddAsOOBView._attach_oob_ip: reuse/re-home or create an interface-assigned IP.""" + + def _view(self): + from netbox_librenms_plugin.views.imports.actions import AddAsOOBView + + return object.__new__(AddAsOOBView) + + def test_invalid_ip_returns_none(self): + view = self._view() + assert view._attach_oob_ip("not-an-ip", MagicMock()) is None + + def test_creates_v4_slash32_when_missing(self): + view = self._view() + iface = MagicMock() + with patch("ipam.models.IPAddress") as mock_ip_cls: + mock_ip_cls.objects.filter.return_value.first.return_value = None + view._attach_oob_ip("10.0.0.9", iface) + mock_ip_cls.objects.create.assert_called_once_with( + address="10.0.0.9/32", assigned_object=iface, status="active" + ) + + def test_rehomes_existing_unassigned_ip(self): + view = self._view() + iface = MagicMock() + iface.device_id = 1 + existing = MagicMock() + existing.assigned_object = None + with patch("ipam.models.IPAddress") as mock_ip_cls: + mock_ip_cls.objects.filter.return_value.first.return_value = existing + result = view._attach_oob_ip("10.0.0.9", iface) + assert result is existing + assert existing.assigned_object is iface + existing.save.assert_called_once() + + def test_does_not_steal_ip_from_other_device(self): + view = self._view() + iface = MagicMock() + iface.device_id = 1 + other_iface = MagicMock() + other_iface.device_id = 2 + existing = MagicMock() + existing.assigned_object = other_iface + with patch("ipam.models.IPAddress") as mock_ip_cls: + mock_ip_cls.objects.filter.return_value.first.return_value = existing + result = view._attach_oob_ip("10.0.0.9", iface) + assert result is None + existing.save.assert_not_called() diff --git a/netbox_librenms_plugin/tests/test_librenms_id.py b/netbox_librenms_plugin/tests/test_librenms_id.py index 2e945d899..dcf525cac 100644 --- a/netbox_librenms_plugin/tests/test_librenms_id.py +++ b/netbox_librenms_plugin/tests/test_librenms_id.py @@ -526,17 +526,22 @@ def test_get_oob_returns_oob_dict_when_present(self): # ── set_librenms_oob ────────────────────────────────────────────────────── def test_set_oob_round_trip(self): - """set_librenms_oob followed by get_librenms_oob returns equivalent values.""" + """set_librenms_oob stores only id + type; ip/version are not persisted. + + Mutable LibreNMS state (the controller's IP and firmware version) is + deliberately NOT denormalised into the librenms_id custom field — it is + read live from LibreNMS via the stored id when needed. + """ from netbox_librenms_plugin.utils import get_librenms_oob, set_librenms_oob obj = MagicMock() obj.custom_field_data = {"librenms_id": {"primary": 42}} obj.cf = obj.custom_field_data - set_librenms_oob(obj, 17, "primary", oob_type="drac", version="5.10", ip="10.0.0.5") + set_librenms_oob(obj, 17, "primary", oob_type="drac") result = get_librenms_oob(obj, "primary") - assert result == {"id": 17, "type": "drac", "version": "5.10", "ip": "10.0.0.5"} + assert result == {"id": 17, "type": "drac"} def test_set_oob_promotes_bare_int_entry(self): """set_librenms_oob promotes a bare-int entry to dict form, preserving the main id.""" diff --git a/netbox_librenms_plugin/utils.py b/netbox_librenms_plugin/utils.py index fbbfa00ab..318a64086 100644 --- a/netbox_librenms_plugin/utils.py +++ b/netbox_librenms_plugin/utils.py @@ -1202,8 +1202,6 @@ def set_librenms_oob( server_key: str = "default", *, oob_type: str, - version: str | None = None, - ip: str | None = None, ) -> None: """ Attach an OOB management controller to a device under *server_key*. @@ -1212,6 +1210,11 @@ def set_librenms_oob( currently a bare integer. Validates *oob_type* against ``OOB_TYPE_PATTERN`` or accepts the generic sentinel ``"oob"`` (used when no specific type keyword can be detected). + Stores only the identity-mapping essentials — the OOB controller's LibreNMS device + ``id`` and a static ``type`` label. Mutable LibreNMS state (the controller's IP and + firmware version) is deliberately NOT persisted here: the IP's source of truth is the + device's interface-assigned ``oob_ip`` IPAddress, and the version belongs in LibreNMS. + Does **not** call ``obj.save()`` — the caller is responsible for persisting the change. Args: @@ -1220,8 +1223,6 @@ def set_librenms_oob( server_key: LibreNMS server key (from plugin ``servers`` config). oob_type: Raw type string (e.g. ``"iDRAC9"``, ``"ilo"``, or the generic ``"oob"``). Will be normalized to lowercase. - version: Optional firmware/software version string. - ip: Optional IP address string of the OOB controller. Raises: ValueError: if *oob_type* does not match any known OOB type and is not the @@ -1263,10 +1264,6 @@ def set_librenms_oob( entry = {} oob: dict = {"id": _oob_id, "type": normalized_type} - if version: - oob["version"] = version - if ip: - oob["ip"] = ip entry["oob"] = oob cf_value[server_key] = entry obj.custom_field_data["librenms_id"] = cf_value diff --git a/netbox_librenms_plugin/views/imports/actions.py b/netbox_librenms_plugin/views/imports/actions.py index c74444cb3..502b18e0b 100644 --- a/netbox_librenms_plugin/views/imports/actions.py +++ b/netbox_librenms_plugin/views/imports/actions.py @@ -2,7 +2,6 @@ import json import logging -from ipaddress import ip_address as _ipaddr_parse from urllib.parse import parse_qs, urlparse from django.contrib import messages @@ -26,7 +25,6 @@ fetch_device_with_cache, get_import_device_cache_key, get_librenms_device_by_id, - get_or_create_global_ip, get_virtual_chassis_data, update_vc_member_suggested_names, validate_device_for_import, @@ -971,6 +969,29 @@ def get(self, request, device_id): ) +def _suggest_oob_interface(device, oob_candidate): + """Return ``(suggested_interface_id, default_new_name)`` for an OOB IP. + + NetBox requires ``oob_ip`` be assigned to one of the device's interfaces, + so the OOB-attach form lets the user pick (or create) one. This pre-selects + the existing interface whose name looks like an OOB/management port + (idrac/ilo/ipmi/bmc/drac/oob/mgmt), or ``None`` if there's no obvious match, + and derives a sensible default name for a new interface from the OOB type + (e.g. ``idrac0``). The OOB IP is frequently *not* physically on the matched + interface — operators attach it to an ``idrac0``-style port deliberately — + so this is only a suggestion the user can override. + """ + import re as _re + + oob_type = (oob_candidate.get("type") or "oob").strip().lower() or "oob" + default_new_name = f"{oob_type}0" + pattern = _re.compile(r"(idrac|ilo|ipmi|bmc|drac|oob|mgmt|management)", _re.IGNORECASE) + for iface in device.interfaces.all(): + if pattern.search(iface.name or ""): + return iface.pk, default_new_name + return None, default_new_name + + class DeviceValidationDetailsView(LibreNMSPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View): """HTMX view to show detailed validation information.""" @@ -1000,6 +1021,15 @@ def get(self, request, device_id): context["sync_info"] = self._build_sync_info(libre_device, existing) context["existing_id_servers"] = self._build_id_server_info(existing) context["existing_device_model_name"] = existing._meta.model_name + # OOB-attach needs an interface to hang the OOB IP on (NetBox requires + # oob_ip be interface-assigned). Offer the device's interfaces with a + # sensible default pre-selected. + if validation.get("oob_candidate") and existing._meta.model_name == "device": + context["oob_interfaces"] = list(existing.interfaces.all()) + ( + context["oob_suggested_interface_id"], + context["oob_default_new_name"], + ) = _suggest_oob_interface(existing, validation["oob_candidate"]) return render( request, @@ -1963,7 +1993,6 @@ def post(self, request, device_id): from netbox_librenms_plugin.utils import set_librenms_oob oob_type = oob_candidate.get("type") or "" - oob_version = oob_candidate.get("version") or None oob_ip_str = oob_candidate.get("ip") or None server_key = self.librenms_api.server_key @@ -1985,28 +2014,35 @@ def post(self, request, device_id): librenms_id, server_key, oob_type=oob_type, - version=oob_version, - ip=oob_ip_str, ) except ValueError as exc: return _htmx_error_response(f"Invalid OOB data: {escape(str(exc))}") update_fields = ["custom_field_data"] - # Assign device.oob_ip if not already set; auto-create the IPAM - # record if it doesn't exist yet so the user has something to - # later attach to an interface and re-home if needed. + + # Set device.oob_ip from an interface-assigned IPAddress. NetBox + # requires oob_ip be assigned to one of the device's interfaces, so + # the user picks (or creates) the interface to hang the OOB IP on + # via the OOB-attach form. Linkage (set_librenms_oob) happened above. if oob_ip_str and existing_device.oob_ip_id is None: - oob_ip, oob_ip_created = get_or_create_global_ip( - oob_ip_str, auto_create=resolve_auto_create_ipam(request) - ) - if oob_ip is not None: - existing_device.oob_ip = oob_ip - update_fields.append("oob_ip") - if oob_ip_created: - messages.info( + oob_iface = self._resolve_oob_interface(request, existing_device) + if oob_iface is None: + messages.info( + request, + "OOB linked. Choose an interface in the OOB form to also set the device's OOB IP.", + ) + else: + oob_ip = self._attach_oob_ip(oob_ip_str, oob_iface) + if oob_ip is None: + messages.warning( request, - f"Auto-created OOB IP {oob_ip_str} in IPAM (unassigned, global scope).", + f"OOB linked, but couldn't set OOB IP {oob_ip_str} " + "(invalid, or already assigned to another device).", ) + else: + existing_device.oob_ip = oob_ip + update_fields.append("oob_ip") + messages.info(request, f"Set OOB IP {oob_ip_str} on interface {oob_iface.name}.") if err := _save_device(existing_device, update_fields=update_fields, request=request): return err @@ -2032,6 +2068,64 @@ def post(self, request, device_id): response["HX-Trigger"] = json.dumps({"validationRefresh": {"deviceId": device_id}}) return response + @staticmethod + def _resolve_oob_interface(request, device): + """Resolve (or create) the interface the OOB IP should attach to. + + Reads ``oob_interface_id`` from the OOB-attach form: an interface PK, or + the sentinel ``"__new__"`` to create one named ``oob_new_interface_name``. + Returns an :class:`Interface` owned by *device*, or ``None`` when the + user made no selection (linkage proceeds without setting ``oob_ip``). + """ + from dcim.models import Interface + + iface_id = (request.POST.get("oob_interface_id") or "").strip() + if iface_id == "__new__": + name = (request.POST.get("oob_new_interface_name") or "").strip() + if not name: + return None + iface, _ = Interface.objects.get_or_create(device=device, name=name, defaults={"type": "other"}) + return iface + if iface_id: + try: + return Interface.objects.get(pk=int(iface_id), device=device) + except (Interface.DoesNotExist, ValueError): + return None + return None + + @staticmethod + def _attach_oob_ip(ip_str, interface): + """Return an :class:`IPAddress` for *ip_str* assigned to *interface*. + + Reuses an existing record for the host (matched via ``net_host`` so any + prefix length is accepted) and re-homes it to *interface*, unless it is + already assigned to a *different* device's object (in which case we do + not steal it and return ``None``). Otherwise creates a ``/32`` (IPv4) or + ``/128`` (IPv6). Returns ``None`` on an invalid address or a conflict. + """ + from ipaddress import ip_address as _ip + + from ipam.models import IPAddress + + try: + parsed = _ip(ip_str) + except ValueError: + return None + + existing = IPAddress.objects.filter(address__net_host=ip_str).first() + if existing is not None: + assigned = existing.assigned_object + owned = assigned is None or getattr(assigned, "device_id", None) == interface.device_id + if not owned: + return None + if assigned != interface: + existing.assigned_object = interface + existing.save() + return existing + + mask = "/128" if parsed.version == 6 else "/32" + return IPAddress.objects.create(address=f"{ip_str}{mask}", assigned_object=interface, status="active") + class PromoteToHostView( LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View @@ -2134,29 +2228,6 @@ def post(self, request, device_id): server_key = self.librenms_api.server_key - # Pull stored OOB metadata (ip/version) from existing librenms link if possible, - # then ensure the incoming device's IP populates oob_ip on the existing device - # only if oob_ip isn't already set. - existing_oob_ip = None - oob_ip_str = None - cf_value = existing_device.custom_field_data.get("librenms_id") - if isinstance(cf_value, dict): - entry = cf_value.get(server_key) - if isinstance(entry, dict): - existing_oob_dict = entry.get("oob") if isinstance(entry.get("oob"), dict) else {} - existing_oob_ip = existing_oob_dict.get("ip") if isinstance(existing_oob_dict, dict) else None - # Prefer existing OOB ip if it was already known; otherwise use existing device's - # current oob_ip relationship; otherwise leave unset (do NOT inherit incoming - # device's IP — that IP belongs to the host, not the OOB controller). - if not existing_oob_ip and existing_device.oob_ip_id: - try: - from ipam.models import IPAddress # noqa - - existing_oob_ip = str(existing_device.oob_ip).split("/")[0] if existing_device.oob_ip else None - except Exception: # pragma: no cover - defensive - existing_oob_ip = None - oob_ip_str = existing_oob_ip or None - # Reject legacy bare-int librenms_id form (caller should migrate first). stored_id = existing_device.custom_field_data.get("librenms_id") _is_legacy = isinstance(stored_id, int) and not isinstance(stored_id, bool) @@ -2171,19 +2242,6 @@ def post(self, request, device_id): "Device has a legacy bare-integer librenms_id; use 'Convert mapping' to migrate first." ) - # Fetch OOB IP from LibreNMS outside the transaction so the row lock - # is not held during a potentially slow network round-trip. - _pre_fetched_oob_ip = None - if not oob_ip_str: - try: - ok, oob_info = self.librenms_api.get_device_info(existing_libre_id) - except Exception: # pragma: no cover - defensive - ok, oob_info = False, None - if ok and isinstance(oob_info, dict): - _fetched = (oob_info.get("ip") or "").strip() or None - if _fetched: - _pre_fetched_oob_ip = _fetched - with transaction.atomic(): try: existing_device = Device.objects.select_for_update().get(pk=existing_device.pk) @@ -2215,90 +2273,17 @@ def post(self, request, device_id): existing_libre_id, server_key, oob_type=oob_type, - ip=oob_ip_str, ) except ValueError as exc: return _htmx_error_response(f"Invalid promotion data: {escape(str(exc))}") - # After promotion, populate IP relationships on the existing device: - # - primary_ip4 / primary_ip6 from the incoming LibreNMS host's IP - # (the device that's now linked as the host) - # - oob_ip from the previously-linked LibreNMS device's IP - # (the device that's now demoted into the OOB slot) - # If the IP does not yet exist in NetBox we create a global /32 (or - # /128) entry so the device row looks complete after promotion; - # the user can re-home / mask it later via the IP-sync flow. - # Both writes are best-effort and never overwrite an already-set - # primary_ip4 / primary_ip6 / oob_ip relationship. + # Promotion re-points the LibreNMS host/OOB linkage only. NetBox + # requires primary_ip4/6 and oob_ip to be assigned to one of the + # device's interfaces, so those relationships are set from the + # interface-assigned IP-sync flow — not from auto-created global + # records here. update_fields = ["custom_field_data"] - host_ip_str = (libre_device.get("ip") or "").strip() or None - if host_ip_str: - _auto_create = resolve_auto_create_ipam(request) - host_ip, host_ip_created = get_or_create_global_ip(host_ip_str, auto_create=_auto_create) - if host_ip is not None: - try: - is_v6 = _ipaddr_parse(host_ip_str).version == 6 - except ValueError: - is_v6 = False - # Only set as primary if the IP is unassigned OR already - # assigned to an interface that belongs to this device. - # Setting a primary IP whose assigned_object belongs to a - # different device would create an inconsistent state. - ao = host_ip.assigned_object - ip_owned_by_device = ao is None or (hasattr(ao, "device_id") and ao.device_id == existing_device.pk) - assigned = False - if ip_owned_by_device: - if is_v6 and existing_device.primary_ip6_id is None: - existing_device.primary_ip6 = host_ip - update_fields.append("primary_ip6") - assigned = True - elif not is_v6 and existing_device.primary_ip4_id is None: - existing_device.primary_ip4 = host_ip - update_fields.append("primary_ip4") - assigned = True - else: - logger.warning( - "Skipping primary IP assignment for %s: IP %s is already assigned " - "to a different object (%r); user must assign it manually.", - existing_device, - host_ip_str, - ao, - ) - if host_ip_created and assigned: - messages.info( - request, - f"Auto-created primary IP {host_ip_str} in IPAM (unassigned, global scope).", - ) - - # Apply pre-fetched OOB IP if we got one and the row is still eligible. - if _pre_fetched_oob_ip and not oob_ip_str and existing_device.oob_ip_id is None: - oob_ip_str = _pre_fetched_oob_ip - # Cache the fetched IP in the OOB sub-object (CFD) while we hold the lock. - try: - set_librenms_oob( - existing_device, - existing_libre_id, - server_key, - oob_type=oob_type, - ip=oob_ip_str, - ) - except ValueError: # pragma: no cover - defensive - pass - - if oob_ip_str and existing_device.oob_ip_id is None: - oob_ip, oob_ip_created = get_or_create_global_ip( - oob_ip_str, auto_create=resolve_auto_create_ipam(request) - ) - if oob_ip is not None: - existing_device.oob_ip = oob_ip - update_fields.append("oob_ip") - if oob_ip_created: - messages.info( - request, - f"Auto-created OOB IP {oob_ip_str} in IPAM (unassigned, global scope).", - ) - # Apply any explicit per-field overrides chosen in the pre-promote modal. # Default behaviour (no overrides) keeps the existing device's name, type # and platform — matching the original promote semantics. diff --git a/netbox_librenms_plugin/views/imports/list.py b/netbox_librenms_plugin/views/imports/list.py index b155611fa..b25914905 100644 --- a/netbox_librenms_plugin/views/imports/list.py +++ b/netbox_librenms_plugin/views/imports/list.py @@ -128,10 +128,6 @@ def _load_job_results(self, job_id, request=None): # Mirror the job's naming settings so toggle state matches the cached results self._use_sysname = use_sysname self._strip_domain = strip_domain - # IPAM auto-create toggle: honour the request-scoped POST/GET override - # first (set by the import page toggle), then fall through to saved pref - # and plugin settings — matches the cascade used by resolve_auto_create_ipam(). - self._auto_create_ipam = resolve_auto_create_ipam(request) return validated_devices @@ -162,7 +158,6 @@ def get(self, request, *args, **kwargs): # noqa: D401 - inherited doc _use_sysname = get_user_pref(request, "plugins.netbox_librenms_plugin.use_sysname") _strip_domain = get_user_pref(request, "plugins.netbox_librenms_plugin.strip_domain") - _auto_create_ipam = get_user_pref(request, "plugins.netbox_librenms_plugin.auto_create_ipam") if _use_sysname is None: _use_sysname = getattr(settings_obj, "use_sysname_default", True) if settings_obj else True if _strip_domain is None: From b863825ece9ea429e6cde071105d0582a7b8c711 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Sun, 31 May 2026 20:52:39 +0200 Subject: [PATCH 03/98] fix(oob-sync): PR #79 review hardening, OOB-flow corrections, and docs Consolidate the PR #79 review follow-ups: OOB/host linkage refresh on cached import rows, multi-server cache-key scoping, HTMX validation-error flows, permission and TOCTOU re-checks (interface/IP attach), partial-fetch cache invalidation for interfaces/modules/cables, VM-aware collision link targeting and import validation, and documentation for OOB management. --- docs/SUMMARY.md | 1 + docs/feature_list.md | 9 + docs/librenms_import/validation.md | 5 + docs/usage_tips/custom_field.md | 7 + docs/usage_tips/oob_management.md | 79 +++ mkdocs.yml | 1 + netbox_librenms_plugin/constants.py | 21 +- .../import_utils/bulk_import.py | 115 +++- .../import_utils/collisions.py | 5 +- .../import_utils/device_operations.py | 35 +- .../import_validation_helpers.py | 16 +- netbox_librenms_plugin/librenms_api.py | 35 +- .../js/librenms_import.js | 8 +- netbox_librenms_plugin/tables/cables.py | 4 +- netbox_librenms_plugin/tables/modules.py | 8 +- .../_cable_sync_content.html | 11 + .../_interface_sync_content.html | 13 +- .../_ipaddress_sync_content.html | 11 + .../_module_sync_content.html | 3 +- .../_vlan_sync_content.html | 11 + .../htmx/_dt_mapping_form.html | 7 +- .../htmx/_oob_interface_select.html | 6 +- .../htmx/_platform_mapping_form.html | 6 +- .../htmx/bulk_import_collision.html | 14 +- .../htmx/device_import_row.html | 2 +- .../htmx/device_validation_details.html | 39 +- .../librenms_sync_base.html | 8 +- .../tests/test_collisions.py | 15 + .../tests/test_coverage_actions.py | 517 +++++++++++++++++- .../tests/test_coverage_base_views.py | 228 +++++++- .../tests/test_coverage_base_views2.py | 97 ++++ .../tests/test_coverage_bulk_import.py | 203 +++++++ .../tests/test_coverage_device_operations.py | 11 +- .../tests/test_coverage_list.py | 6 +- .../tests/test_coverage_mixins.py | 47 ++ .../tests/test_coverage_sync_interfaces.py | 72 +++ .../tests/test_coverage_sync_views2.py | 29 + .../tests/test_coverage_utils.py | 20 + .../tests/test_import_utils.py | 24 +- .../tests/test_import_validation_helpers.py | 35 ++ .../tests/test_ip_verify.py | 24 + .../tests/test_librenms_api.py | 24 + .../tests/test_librenms_id.py | 153 +++++- .../tests/test_migrate_views.py | 205 ++++++- .../tests/test_modules_view.py | 159 ++++++ .../tests/test_reviewer_fixes.py | 43 ++ .../tests/test_sync_view_mismatch.py | 34 ++ .../tests/test_tables_modules.py | 17 + .../tests/test_vlan_sync.py | 119 ++++ netbox_librenms_plugin/utils.py | 125 ++++- .../views/base/cables_view.py | 158 +++++- .../views/base/interfaces_view.py | 128 ++++- .../views/base/ip_addresses_view.py | 145 +++-- .../views/base/librenms_sync_view.py | 32 +- .../views/base/modules_view.py | 105 +++- .../views/base/vlan_table_view.py | 61 ++- .../views/imports/actions.py | 422 +++++++++++--- netbox_librenms_plugin/views/imports/list.py | 4 +- netbox_librenms_plugin/views/mixins.py | 28 + .../views/sync/ip_addresses.py | 14 +- netbox_librenms_plugin/views/sync/migrate.py | 64 ++- 61 files changed, 3477 insertions(+), 371 deletions(-) create mode 100644 docs/usage_tips/oob_management.md diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 8f1a38b80..f3cbe444f 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -16,6 +16,7 @@ * [Background Jobs & Caching](librenms_import/background_jobs.md) * [Sync & Configuration](usage_tips/virtual_chassis.md) * [Virtual Chassis](usage_tips/virtual_chassis.md) + * [Out-of-Band Management](usage_tips/oob_management.md) * [Interface Mappings](usage_tips/interface_mappings.md) * [Module Sync](usage_tips/module_sync.md) * [Mapping Rules](usage_tips/mapping_rules.md) diff --git a/docs/feature_list.md b/docs/feature_list.md index c46a70702..eeb4ac5bb 100644 --- a/docs/feature_list.md +++ b/docs/feature_list.md @@ -10,6 +10,15 @@ * Background job processing for large device sets * Duplicate detection to prevent re-importing existing devices +### [Out-of-Band (OOB) Management](usage_tips/oob_management.md) + +* Detects when a LibreNMS device (iDRAC/iLO/BMC/IPMI/CIMC) is the OOB controller of an existing NetBox device +* **Add as OOB** — link the controller to the host and set `oob_ip` on a chosen (or new) interface +* **Promote to host** — re-point a device currently linked to its OOB controller onto the incoming host device +* **Merge NetBox devices** — reconcile two devices (hostname-matched vs serial-matched) that represent one physical box +* Per-server linkage stored in the `librenms_id` custom field as `{"": {"id": N, "oob": {"id": M, "type": "drac"}}}` +* Post-merge **Move to winner** actions to migrate interfaces, IP addresses, and primary/OOB IPs at your own pace + ### [Module / Inventory Sync](usage_tips/module_sync.md) * Compare LibreNMS ENTITY-MIB inventory to NetBox module bays and installed modules diff --git a/docs/librenms_import/validation.md b/docs/librenms_import/validation.md index 4dd9158aa..a1d7aaddb 100644 --- a/docs/librenms_import/validation.md +++ b/docs/librenms_import/validation.md @@ -46,6 +46,11 @@ The plugin checks for existing devices using: If both a VM and Device with the same hostname exist, the plugin cannot determine which to match and allows import. Set the `librenms_id` custom field on the correct existing object to clarify the match. +## Out-of-Band (OOB) Detection + +When an incoming LibreNMS device looks like an out-of-band controller (iDRAC, iLO, BMC, …) and matches an existing NetBox device, the validation details show an **OOB Detected** panel instead of a plain import button. Rather than creating a duplicate device, the plugin offers the appropriate reconciliation action — **Add as OOB**, **Promote to host**, or **Merge NetBox devices**. See [Out-of-Band (OOB) Management](../usage_tips/oob_management.md) for the full flow. + ## Next Steps - [Import Settings](import_settings.md) - Configure device naming and import options +- [Out-of-Band Management](../usage_tips/oob_management.md) - Reconcile OOB controllers with their host devices diff --git a/docs/usage_tips/custom_field.md b/docs/usage_tips/custom_field.md index 0295a1e05..1b5757571 100644 --- a/docs/usage_tips/custom_field.md +++ b/docs/usage_tips/custom_field.md @@ -49,6 +49,13 @@ If the field was not created automatically (fallback): follow these steps to cre ```json {"production": 42, "staging": 17} ``` + - Out-of-band (OOB) form — when a device is linked to its OOB controller, the per-server value is an object holding the host id and the controller's id/type: + + ```json + {"production": {"id": 42, "oob": {"id": 99, "type": "drac"}}} + ``` + + This shape is written automatically by the OOB flows — see [Out-of-Band Management](oob_management.md). You don't normally edit it by hand. - Legacy single-server example (integer) — read-only/deprecated; do not use for new entries: ``` 42 diff --git a/docs/usage_tips/oob_management.md b/docs/usage_tips/oob_management.md new file mode 100644 index 000000000..119ecaa27 --- /dev/null +++ b/docs/usage_tips/oob_management.md @@ -0,0 +1,79 @@ +# Out-of-Band (OOB) Management + +Many servers expose a dedicated **out-of-band management controller** — iDRAC, iLO, BMC, IPMI, CIMC, and similar. LibreNMS usually polls that controller as its **own device**, separate from the host it lives in. NetBox models the same relationship differently: the controller is not a separate Device — its address is the host Device's **OOB IP** (`oob_ip`). + +This plugin bridges the two models. During import it detects when an incoming LibreNMS device is really the OOB side of a host you already have, and offers the right action to reconcile them instead of creating a duplicate device. + +## How the link is stored + +OOB linkage is recorded in the `librenms_id` [custom field](custom_field.md) alongside the host's own LibreNMS ID. The per-server value is promoted from a bare integer to a small object: + +```json +{ + "production": { + "id": 42, + "oob": { "id": 99, "type": "drac" } + } +} +``` + +- `id` — the LibreNMS device ID of the **host**. +- `oob.id` — the LibreNMS device ID of the **OOB controller**. +- `oob.type` — a short label for the controller (`drac`, `ilo`, `bmc`, `ipmi`, `cimc`, …), or the generic `oob` when the specific type can't be determined. + +Only these identity essentials are stored. The controller's IP and firmware version are intentionally **not** persisted here — the IP's source of truth is the host Device's interface-assigned `oob_ip`, and the version lives in LibreNMS and can be read back any time from `oob.id`. + +## OOB detection during import + +When a searched LibreNMS device looks like an OOB controller (by its OS/hardware strings, e.g. an iDRAC) and matches an existing NetBox device, the validation details show an **OOB Detected** panel instead of a plain import button. From there one of three resolution flows is offered, depending on what already exists. + +### Add as OOB + +Use when the existing NetBox device is the **host** and the incoming LibreNMS device is its OOB controller. + +The **Add as OOB to *device*** action links the controller's LibreNMS ID into the host's `oob.id` slot. NetBox requires `oob_ip` to be assigned to one of the device's interfaces, so the form includes an **OOB IP interface** picker: + +- A sensible interface is **pre-selected** (matched by name — `idrac`/`ilo`/`bmc`-style). Because the OOB IP is frequently *not* physically on that interface, the selection is **overridable**. +- Choose **+ Create new interface…** to create one (default name suggested) to hang the OOB IP on. + +The OOB IP is then created (or re-homed) assigned to the chosen interface and set as the device's `oob_ip`. If you make no interface selection, the link is still recorded and the OOB IP is left for you to set later. + +!!! note "Permissions" + Setting the OOB IP can create an Interface, create an IPAddress, or re-home an existing one. The action requires the matching NetBox `add`/`change` permissions for those models; if you lack them the link is still recorded and the IP step is skipped with a warning. See [Permissions & Access](permissions.md). + +### Promote to host + +Use when the existing NetBox device is currently linked to the **OOB controller** (its `librenms_id` points at the controller) and the incoming LibreNMS device is the **host** side. + +**Promote to host of *device*** re-points the linkage: the incoming host's LibreNMS ID becomes the device's `id`, and the previously-linked controller ID is demoted into the `oob` slot. No new device is created. A pre-promote modal lets you optionally override the device's **name**, **device type**, and **platform** — all default to **Keep current**, so the original promote behaviour is unchanged unless you explicitly choose **Use new**. + +### Merge NetBox devices + +Use when **two different NetBox devices** turn out to represent one physical box — typically one created from the LibreNMS hostname and another from the chassis serial, where at least one already carries a LibreNMS link. + +The validation modal lists both candidates (hostname-matched and serial-matched) with their current linkage, and you pick which one to **keep** (the *winner*) and which to absorb (the *donor*). Merging consolidates the donor's LibreNMS link state under the active server key into the winner, clears the donor's active link, and writes a `_migrated_to` marker on the donor pointing at the winner. Interfaces, cables, and primary/OOB IPs are **not** moved automatically — you re-home those incrementally (see below). + +## Migrating a donor device after a merge + +A donor device (one with a `_migrated_to` marker) shows a banner on its LibreNMS sync page with **Move to winner** actions, so you can move resources over at your own pace: + +- **Move interface to winner** — reassigns an interface (and the cables, IPs, and MACs that hang off it) to the winner. Fails if the winner already has an interface with the same name — rename or remove that one first. +- **Move IP address to winner** — re-homes an interface-assigned IP to the winner's same-named interface (move the interface first if it doesn't exist on the winner yet). +- **Transfer primary IPv4 / IPv6 / OOB IP** — points the winner's `primary_ip4` / `primary_ip6` / `oob_ip` foreign key at the donor's value and clears it on the donor. Refuses to overwrite a value already set on the winner — clear it there first. + +Each action runs under a row lock and verifies the `_migrated_to` marker before touching anything. Once the donor has nothing left to migrate you can delete it. + +## Setting Primary and OOB IPs in general + +Outside the OOB import flows, both `primary_ip` and `oob_ip` are driven from interface-assigned addresses: + +- **Primary IP** is set on the [IP Address sync tab](../librenms_import/overview.md): with **Set Primary IP** enabled, a synced IP that matches the LibreNMS management IP and is interface-assigned becomes the device's primary. +- **OOB IP** is set through the **Add as OOB** flow above. + +This keeps every IP relationship valid against NetBox's requirement that primary/OOB IPs be assigned to one of the device's own interfaces. + +## See also + +- [Custom Field Setup](custom_field.md) — the `librenms_id` field that stores the linkage. +- [Validation & Configuration](../librenms_import/validation.md) — where OOB is detected during import. +- [Permissions & Access](permissions.md) — permissions required for the OOB/IP actions. diff --git a/mkdocs.yml b/mkdocs.yml index e38dd729a..efa426418 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -20,6 +20,7 @@ nav: - Background Jobs & Caching: librenms_import/background_jobs.md - Sync & Configuration: - Virtual Chassis: usage_tips/virtual_chassis.md + - Out-of-Band Management: usage_tips/oob_management.md - Interface Mappings: usage_tips/interface_mappings.md - Module Sync: usage_tips/module_sync.md - Mapping Rules: usage_tips/mapping_rules.md diff --git a/netbox_librenms_plugin/constants.py b/netbox_librenms_plugin/constants.py index b4892c03c..d6db5c9bb 100644 --- a/netbox_librenms_plugin/constants.py +++ b/netbox_librenms_plugin/constants.py @@ -8,8 +8,8 @@ LIBRENMS_VLAN_STATE_ACTIVE = 1 # OOB management controller detection -OOB_TYPE_PATTERN = re.compile(r"\b(idrac|ilo|ipmi|bmc|drac|oob)", re.IGNORECASE) -OOB_TYPES = ("idrac", "ilo", "ipmi", "bmc", "drac", "oob") +OOB_TYPE_PATTERN = re.compile(r"\b(idrac|ilo|ipmi|bmc|drac|cimc|oob)", re.IGNORECASE) +OOB_TYPES = ("idrac", "ilo", "ipmi", "bmc", "drac", "cimc", "oob") def normalize_oob_type(os_str: str, hardware_str: str = "") -> str | None: @@ -18,13 +18,22 @@ def normalize_oob_type(os_str: str, hardware_str: str = "") -> str | None: Returns the canonical lowercase token (one of OOB_TYPES) or None if no match. + A vendor-specific match (idrac/ilo/ipmi/bmc/drac/cimc) always wins over the + generic ``oob`` token, even when ``oob`` appears earlier in the text, so e.g. + ``normalize_oob_type("oob", "iDRAC9")`` resolves to ``"idrac"`` rather than + being masked by the generic token. + Examples: normalize_oob_type("drac9", "iDRAC9") → "drac" + normalize_oob_type("oob", "iDRAC9") → "idrac" normalize_oob_type("ilo", "") → "ilo" normalize_oob_type("ubuntu", "") → None """ + generic = None for text in (os_str or "", hardware_str or ""): - m = OOB_TYPE_PATTERN.search(text) - if m: - return m.group(1).lower() - return None + for m in OOB_TYPE_PATTERN.finditer(text): + token = m.group(1).lower() + if token != "oob": + return token # vendor-specific match wins immediately + generic = generic or "oob" # remember the generic fallback, keep scanning + return generic diff --git a/netbox_librenms_plugin/import_utils/bulk_import.py b/netbox_librenms_plugin/import_utils/bulk_import.py index 93962432e..84c836ccd 100644 --- a/netbox_librenms_plugin/import_utils/bulk_import.py +++ b/netbox_librenms_plugin/import_utils/bulk_import.py @@ -8,9 +8,13 @@ from ..import_validation_helpers import apply_role_to_validation, recalculate_validation_status, remove_validation_issue from ..librenms_api import LibreNMSAPI -from ..utils import find_by_librenms_id +from ..utils import coerce_librenms_id, find_by_librenms_id, get_librenms_oob 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 .device_operations import ( + _describe_existing_librenms_link, + import_single_device, + validate_device_for_import, +) from .filters import _safe_disabled, get_librenms_devices_for_import from .permissions import check_user_permissions, require_permissions from .virtual_chassis import ( @@ -334,6 +338,52 @@ def bulk_import_devices( ) +def _refresh_librenms_linkage(validation: dict, device, libre_device: dict, server_key: str) -> None: + """Re-derive the LibreNMS-id linkage fields for a refreshed *device*. + + Cheap and DB-only (reads the device's ``librenms_id`` custom field) — no + LibreNMS API call — so a cached import row picks up OOB-link / host-link + changes made in NetBox since the row was cached. Without this, the cache-hit + path keeps the stale ``existing_match_type``/badge (e.g. an OOB controller + linked after caching still rendered as a conflict until the cache expired). + + Mirrors ``validate_device_for_import``'s linkage logic: always refreshes + ``existing_librenms_link``, and when the device is matched to the scanned + LibreNMS id it classifies the match as ``librenms_oob`` (matched via the OOB + sub-key) or ``librenms_id`` (matched as the host). + """ + link = _describe_existing_librenms_link(device, server_key) + validation["existing_librenms_link"] = link + # Only re-classify librenms-id-based matches; leave serial/hostname/primary_ip + # match types untouched. + if validation.get("existing_match_type") in ("librenms_id", "librenms_oob"): + scanned_id = coerce_librenms_id((libre_device or {}).get("device_id")) + oob = get_librenms_oob(device, server_key=server_key) + oob_id = coerce_librenms_id(oob.get("id")) if oob else None + if scanned_id is not None and oob_id is not None and oob_id == scanned_id: + validation["existing_match_type"] = "librenms_oob" + elif scanned_id is not None and link["host_id"] is not None and link["host_id"] == scanned_id: + # Host id still matches the scanned device — a genuine host-side link. + validation["existing_match_type"] = "librenms_id" + else: + # Linkage changed since caching: neither the host id nor the OOB id matches + # the scanned device anymore, so don't keep a stale librenms_id badge. + validation["existing_match_type"] = None + + +def _clear_existing_match_derived_fields(validation: dict) -> None: + """Reset the fields produced from an existing match so stale serial/OOB/merge/promote + actions don't linger after that match is dropped (device deleted, or librenms/OOB link + removed since caching). The subsequent fresh lookup re-populates them if it re-matches. + """ + validation["serial_action"] = None + validation["oob_candidate"] = None + validation["serial_role_choice_available"] = False + # promote_to_host follows the "absent otherwise" contract (see apply_oob_detection_result). + validation.pop("promote_to_host", None) + validation.pop("merge_candidates", None) + + 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. @@ -354,26 +404,53 @@ def _refresh_existing_device(validation: dict, libre_device: dict = None, server if refreshed: validation["existing_device"] = refreshed - if hasattr(refreshed, "role") and refreshed.role: - apply_role_to_validation(validation, refreshed.role, is_vm=bool(validation.get("import_as_vm"))) - elif not validation.get("import_as_vm"): - validation["device_role"] = { - "found": False, - "role": None, - "available_roles": validation.get("device_role", {}).get("available_roles", []), - } - remove_validation_issue(validation, "role") - recalculate_validation_status(validation, is_vm=bool(validation.get("import_as_vm"))) - # Re-assert non-importable state: recalculate bases can_import on - # issues alone, but an existing matched device must never be import-ready. - validation["can_import"] = False - validation["is_ready"] = False - return + # Re-derive linkage so an OOB-link/host-link change since caching + # is reflected in the badge (DB-only; no LibreNMS API call). + prior_match = validation.get("existing_match_type") + _refresh_librenms_linkage(validation, refreshed, libre_device, server_key) + if prior_match in ("librenms_id", "librenms_oob") and validation.get("existing_match_type") is None: + # The librenms-id/OOB link that made this the cached match is gone + # (removed/repointed in NetBox since caching). Treat it like a vanished + # match — clear it and recompute readiness, then fall through to the fresh + # lookup below so the row is re-evaluated under current rules (it may now + # match by hostname/serial/IP, or become importable as new) instead of + # staying blocked until cache expiry. Mirrors the deleted-device branch. + validation["existing_device"] = None + validation["existing_librenms_link"] = None + _clear_existing_match_derived_fields(validation) + if not validation.get("import_as_vm"): + validation["device_role"] = { + "found": False, + "role": None, + "available_roles": validation.get("device_role", {}).get("available_roles", []), + } + recalculate_validation_status(validation, is_vm=bool(validation.get("import_as_vm"))) + else: + if hasattr(refreshed, "role") and refreshed.role: + apply_role_to_validation(validation, refreshed.role, is_vm=bool(validation.get("import_as_vm"))) + elif not validation.get("import_as_vm"): + validation["device_role"] = { + "found": False, + "role": None, + "available_roles": validation.get("device_role", {}).get("available_roles", []), + } + remove_validation_issue(validation, "role") + recalculate_validation_status(validation, is_vm=bool(validation.get("import_as_vm"))) + # Re-assert non-importable state: recalculate bases can_import on + # issues alone, but an existing matched device must never be import-ready. + validation["can_import"] = False + validation["is_ready"] = False + return else: # Device was deleted since caching — recompute readiness to match # validate_device_for_import logic. validation["existing_device"] = None validation["existing_match_type"] = None + # Nothing is linked anymore — clear the linkage so the row can't + # keep rendering a stale host/OOB badge. + validation["existing_librenms_link"] = None + # Drop serial/OOB/merge/promote actions that pointed at the deleted device. + _clear_existing_match_derived_fields(validation) # Clear stale device_role so is_ready is computed from scratch. # Guard: VMs don't use device_role for readiness, so preserve any # user-selected role rather than silently dropping it. @@ -446,6 +523,10 @@ def _lookup_in_model(m): if new_device: validation["existing_device"] = new_device validation["existing_match_type"] = match_type + # Re-derive linkage so a librenms_id match is correctly shown as the + # host vs. OOB half, and existing_librenms_link is populated for the + # paired badge (DB-only; no LibreNMS API call). + _refresh_librenms_linkage(validation, new_device, libre_device, server_key) validation["can_import"] = False validation["is_ready"] = False # Determine actual model from the found object, not from import_as_vm flag diff --git a/netbox_librenms_plugin/import_utils/collisions.py b/netbox_librenms_plugin/import_utils/collisions.py index 41d19d014..d0d4976ce 100644 --- a/netbox_librenms_plugin/import_utils/collisions.py +++ b/netbox_librenms_plugin/import_utils/collisions.py @@ -127,7 +127,7 @@ def detect_bulk_collisions(devices: list[dict] | None) -> list[dict]: bucket_key = (model_name, nb_pk) bucket = by_nb_pk.setdefault( bucket_key, - {"nb_device_pk": nb_pk, "nb_device_name": nb_name, "_rows": {}}, + {"nb_device_pk": nb_pk, "nb_device_name": nb_name, "nb_model_name": model_name, "_rows": {}}, ) # Keep the first non-default name we see — rows often disagree # on the cached display string, but the underlying pk is the @@ -153,6 +153,9 @@ def detect_bulk_collisions(devices: list[dict] | None) -> list[dict]: { "nb_device_pk": bucket["nb_device_pk"], "nb_device_name": bucket["nb_device_name"], + # Class name ("Device"/"VirtualMachine") so the template links to the right + # object type — a VM collision must not render a dcim:device URL. + "nb_model_name": bucket["nb_model_name"], "librenms_rows": [ { "device_id": r["device_id"], diff --git a/netbox_librenms_plugin/import_utils/device_operations.py b/netbox_librenms_plugin/import_utils/device_operations.py index 296435259..4e00d9551 100644 --- a/netbox_librenms_plugin/import_utils/device_operations.py +++ b/netbox_librenms_plugin/import_utils/device_operations.py @@ -475,6 +475,9 @@ def validate_device_for_import( logger.info(f"Found existing device by hostname: {existing_device.name}") result["existing_device"] = existing_device result["existing_match_type"] = "hostname" + # Surface the current host/OOB linkage so a hostname-matched device that + # is already linked to LibreNMS isn't mislabelled as "not linked". + result["existing_librenms_link"] = _describe_existing_librenms_link(existing_device, server_key) # Check for serial conflict on hostname-matched device incoming_serial = libre_device.get("serial") or "" @@ -497,8 +500,15 @@ def validate_device_for_import( f"LibreNMS: '{incoming_serial}'). Hardware may have been replaced." ) else: + existing_link = result["existing_librenms_link"] or {} + if existing_link.get("host_id"): + link_note = f"currently linked to LibreNMS device #{existing_link['host_id']}" + elif existing_link.get("oob_id"): + link_note = "OOB already linked" + else: + link_note = "not linked to LibreNMS" result["warnings"].append( - f"Device with same hostname exists in NetBox as '{existing_device.name}' (not linked to LibreNMS)" + f"Device with same hostname exists in NetBox as '{existing_device.name}' ({link_note})" ) result["can_import"] = False @@ -722,6 +732,10 @@ def validate_device_for_import( else None ) if device: + # Surface any existing host/OOB linkage so the import UI renders the + # correct row state (the librenms_id / serial branches do the same; + # without this an already-linked device shows as "not linked" here). + result["existing_librenms_link"] = _describe_existing_librenms_link(device, server_key) # Check if this is an OOB candidate via the IP path. # The OOB controller's IP may already be the device's oob_ip, or the # LibreNMS device may identify itself as an OOB type (iDRAC/iLO/etc.). @@ -755,11 +769,28 @@ def validate_device_for_import( else: result["existing_device"] = device result["existing_match_type"] = "primary_ip" + # Line 728 may already have populated a host/OOB + # linkage; describe it accurately instead of always + # claiming "not linked to LibreNMS". + existing_link = result.get("existing_librenms_link") or {} + if existing_link.get("host_id"): + link_note = f"currently linked to LibreNMS device #{existing_link['host_id']}" + elif existing_link.get("oob_id"): + link_note = "OOB already linked" + else: + link_note = "not linked to LibreNMS" result["warnings"].append( - f"IP address {primary_ip} already assigned to device '{device.name}' (not linked to LibreNMS)" + f"IP address {primary_ip} already assigned to device '{device.name}' ({link_note})" ) result["can_import"] = False + # Refresh local mode after ALL detection branches. The refresh at the top of the + # unmatched-device block only runs when nothing matched by librenms_id; an existing + # VM matched directly by librenms_id (above) sets result["import_as_vm"]=True but + # skips that block, so without this a linked VM would wrongly take the Device path + # (missing cluster["available_clusters"], running device-only validation/VC detection). + import_as_vm = result["import_as_vm"] + # Validate based on import type (Device or VM) if import_as_vm: # Always populate available clusters for all VMs (new or existing) so diff --git a/netbox_librenms_plugin/import_validation_helpers.py b/netbox_librenms_plugin/import_validation_helpers.py index 718df466a..a3f6ad755 100644 --- a/netbox_librenms_plugin/import_validation_helpers.py +++ b/netbox_librenms_plugin/import_validation_helpers.py @@ -167,8 +167,18 @@ def apply_oob_detection_result( """ result["serial_action"] = serial_action result["oob_candidate"] = oob_candidate - result["promote_to_host"] = promote_to_host + # Honor the "absent otherwise" contract: only carry promote_to_host when a real + # promotion target exists, clearing any stale key rather than storing a None sentinel. + if promote_to_host is None: + result.pop("promote_to_host", None) + else: + result["promote_to_host"] = promote_to_host result["serial_role_choice_available"] = serial_role_choice_available + # Clear merge-only state: this is the non-merge path, so if the same result + # dict was previously marked a merge candidate, the stale merge UI data must + # not linger (apply_merge_candidates is the only writer of merge_candidates). + result["merge_candidates"] = None + result.setdefault("warnings", []) for warning in warnings or []: result["warnings"].append(warning) @@ -203,8 +213,10 @@ def apply_merge_candidates( } result["can_import"] = False result["oob_candidate"] = None - result["promote_to_host"] = None + # "absent otherwise" contract — the merge path has no promotion target. + result.pop("promote_to_host", None) result["serial_role_choice_available"] = False + result.setdefault("warnings", []) result["warnings"].append(warning) diff --git a/netbox_librenms_plugin/librenms_api.py b/netbox_librenms_plugin/librenms_api.py index d5d649114..9e3848984 100644 --- a/netbox_librenms_plugin/librenms_api.py +++ b/netbox_librenms_plugin/librenms_api.py @@ -12,6 +12,22 @@ logger = logging.getLogger(__name__) +def build_librenms_api(server_key): + """Return a :class:`LibreNMSAPI` for *server_key*, or ``None`` when the key is + unknown or the server is misconfigured. + + ``LibreNMSAPI(server_key=...)`` raises ``KeyError`` for an unknown non-default + key and ``ValueError`` when the URL/token is missing. Views take ``server_key`` + from request POST, where a stale page or tampered request can carry a key that + no longer exists — returning ``None`` lets the caller surface a user-facing + error instead of an unhandled 500. + """ + try: + return LibreNMSAPI(server_key=server_key) + except (KeyError, ValueError): + return None + + class LibreNMSAPI: """ Client to interact with the LibreNMS API and retrieve interface data for devices. @@ -65,8 +81,17 @@ def __init__(self, server_key=None): if servers_config and isinstance(servers_config, dict) and server_key in servers_config: # Multi-server configuration config = servers_config[server_key] - self.librenms_url = config["librenms_url"] - self.api_token = config["api_token"] + # A structurally invalid entry (None / non-mapping) would raise TypeError on + # the dict access below and bypass the ValueError contract that callers like + # build_librenms_api() rely on to fall back to None. Fail with ValueError, and + # read keys with .get() so a missing url/token is caught by the check below. + if not isinstance(config, dict): + raise ValueError( + f"LibreNMS server '{server_key}' is misconfigured " + f"(expected a mapping, got {type(config).__name__})." + ) + self.librenms_url = config.get("librenms_url") + self.api_token = config.get("api_token") self.cache_timeout = config.get("cache_timeout", 300) self.verify_ssl = config.get("verify_ssl", True) else: @@ -160,6 +185,12 @@ def get_available_servers(cls): # Multi-server configuration result = {} for key, config in servers_config.items(): + # Skip structurally invalid entries (e.g. {"prod": None}); mirrors the + # mapping guard in __init__ so a malformed config can't crash the server + # selector here with AttributeError on config.get(). + if not isinstance(config, dict): + logger.warning("Skipping malformed LibreNMS server config %r (expected a mapping).", key) + continue display_name = config.get("display_name", key) result[key] = display_name return result 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 c152cc080..8bcac3736 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 @@ -1226,14 +1226,20 @@ // then `window.bootstrap` as fallback, then plain DOM toggling. document.querySelectorAll('#htmx-modal-content .modal.show').forEach(function (nested) { try { + // Prefer Bootstrap: it tracks stacked modals and leaves the + // outer HTMX modal's backdrop/body state intact. Only fall + // back to a minimal DOM hide — never hideModal()/_hideManual, + // which strips body.modal-open and the (shared, outer) backdrop + // and would break the still-open outer modal. if (typeof bootstrap !== 'undefined' && bootstrap.Modal) { bootstrap.Modal.getOrCreateInstance(nested).hide(); - } else if (window.bootstrap && window.bootstrap.Modal) { + } else if (typeof window.bootstrap !== 'undefined' && window.bootstrap.Modal) { window.bootstrap.Modal.getOrCreateInstance(nested).hide(); } else { nested.classList.remove('show'); nested.style.display = 'none'; nested.setAttribute('aria-hidden', 'true'); + nested.removeAttribute('aria-modal'); } } catch (err) { // Swallow - we still want to refresh the validation panel. diff --git a/netbox_librenms_plugin/tables/cables.py b/netbox_librenms_plugin/tables/cables.py index 5bf3f479c..d8bf89367 100644 --- a/netbox_librenms_plugin/tables/cables.py +++ b/netbox_librenms_plugin/tables/cables.py @@ -61,8 +61,10 @@ def render_remote_device(self, value, record): def render_local_port(self, value, record): """Render local port name as a link if URL is available.""" + # Static trusted markup — use mark_safe, not format_html (which requires + # interpolation args and raises TypeError when given a bare string in Django 6+). oob_badge = ( - format_html(' OOB') + mark_safe(' OOB') # noqa: S308 if record.get("_source") == "oob" else "" ) diff --git a/netbox_librenms_plugin/tables/modules.py b/netbox_librenms_plugin/tables/modules.py index 9b99b688b..015c719f7 100644 --- a/netbox_librenms_plugin/tables/modules.py +++ b/netbox_librenms_plugin/tables/modules.py @@ -147,8 +147,10 @@ def render_name(self, value, record): rendered_name = display_name depth = record.get("depth", 0) + # Static trusted markup — use mark_safe, not format_html (which requires + # interpolation args and raises TypeError when given a bare string). oob_badge = ( - format_html(' OOB') + mark_safe('OOB') # noqa: S308 if record.get("_source") == "oob" else "" ) @@ -157,8 +159,10 @@ def render_name(self, value, record): # Build visual tree prefix based on nesting depth padding_px = depth * 20 prefix = "└─ " + # Keep the OOB badge inside the padded container so it stays indented + # with the module name on nested rows (was rendering at column 0). return format_html( - '{}{}{}', + '{}{}{}', padding_px, prefix, rendered_name, diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_cable_sync_content.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_cable_sync_content.html index 485676360..30222fb88 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_cable_sync_content.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_cable_sync_content.html @@ -3,16 +3,23 @@ {% if cable_sync.table %} +{# Migrated donors must not sync: hiding only the button leaves the POST form live (Enter in a filter still submits), so drop the form in migrated mode. #} +{% if migrated_to_marker %} +
+{% else %} +{% endif %} {% csrf_token %} {% if cable_sync.server_key %}{% endif %}
+ {% if not migrated_to_marker %} + {% endif %} info @@ -70,7 +77,11 @@
+{% if migrated_to_marker %} +
+{% else %} +{% endif %} {% else %}
diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html index cd0cfc951..664551283 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html @@ -4,19 +4,26 @@ {% if interface_sync.table %} +{# Migrated donors must not sync: hiding only the button leaves the POST form live (Enter in a filter still submits), so drop the form in migrated mode. #} {% with model_name=interface_sync.object|meta:"model_name" %} +{% if migrated_to_marker %} +
+{% else %}
+{% endif %} {% endwith %} {% csrf_token %} {% if interface_sync.server_key %}{% endif %} {% block table_actions %}
+ {% if not migrated_to_marker %} + {% endif %} info @@ -198,7 +205,11 @@
+{% if migrated_to_marker %} +
+{% else %} +{% endif %} {% else %}
@@ -338,7 +349,7 @@
+{% if migrated_to_marker %} +
+{% else %} +{% endif %} diff --git a/netbox_librenms_plugin/tests/test_coverage_actions.py b/netbox_librenms_plugin/tests/test_coverage_actions.py index b7efa6d7e..b7d1dd042 100644 --- a/netbox_librenms_plugin/tests/test_coverage_actions.py +++ b/netbox_librenms_plugin/tests/test_coverage_actions.py @@ -5554,3 +5554,84 @@ def test_invalid_manufacturer_id_is_rejected(self): # Neither the constructor nor the manager create() path persisted a Platform. mock_platform.assert_not_called() mock_platform.objects.create.assert_not_called() + + +class TestOOBInterfaceSelectTemplate: + """The OOB interface picker toggles the "new name" input via a script block + (extracted from an inline onchange) so it works under CSP and is maintainable.""" + + def _render(self): + from django.template.loader import render_to_string + + return render_to_string( + "netbox_librenms_plugin/htmx/_oob_interface_select.html", + { + "libre_device": {"device_id": 7}, + "oob_interfaces": [], + "oob_suggested_interface_id": None, + "oob_default_new_name": "", + "validation": {}, + }, + ) + + def test_no_inline_onchange_handler(self): + assert "onchange=" not in self._render() + + def test_wires_change_handler_via_script_block(self): + html = self._render() + assert 'addEventListener("change"' in html + # Targets this device's own select id (namespaced by device_id). + assert 'getElementById("oob-iface-7")' in html + + +class TestMergeNetBoxDevicesViewDonorDerivation: + """The merge view derives the donor from winner_pk + merge_candidates and + ignores the client-posted donor_pk, which a stale/failed inline sync script + could otherwise leave equal to winner_pk (a self-merge of moving data).""" + + def _make_view(self): + from netbox_librenms_plugin.views.imports.actions import MergeNetBoxDevicesView + + view = object.__new__(MergeNetBoxDevicesView) + view._librenms_api = _make_api() + view.require_write_permission = MagicMock(return_value=None) + view.require_object_permissions = MagicMock(return_value=None) + return view + + def test_ignores_posted_donor_pk_equal_to_winner(self): + from django.http import HttpResponse + + view = self._make_view() + # Bogus client state: donor_pk == winner_pk (inline sync script never ran). + request = _make_request(post={"winner_pk": "20", "donor_pk": "20"}) + + winner = MagicMock(pk=20, custom_field_data={"librenms_id": {"default": {"id": 20}}}) + donor = MagicMock(pk=10, custom_field_data={"librenms_id": {"default": {"id": 10}}}) + locked_winner = MagicMock(pk=20, name="w", oob_ip_id=None, oob_ip=None) + locked_donor = MagicMock(pk=10, name="d", oob_ip_id=None, oob_ip=None) + + validation = {"merge_candidates": {"host_named": {"pk": 20}, "oob_named": {"pk": 10}}} + view.get_validated_device_with_selections = MagicMock(return_value=({"device_id": 99}, validation, {})) + view.render_device_row = MagicMock(return_value=HttpResponse("row")) + + with ( + patch("dcim.models.Device") as mock_device, + patch("netbox_librenms_plugin.views.imports.actions.transaction"), + patch("netbox_librenms_plugin.views.imports.actions.cache"), + patch("netbox_librenms_plugin.utils.merge_librenms_links", return_value={}) as mock_merge, + patch("netbox_librenms_plugin.utils.mark_librenms_migrated"), + ): + mock_device.DoesNotExist = Exception + mock_device.objects.get.side_effect = lambda pk: {20: winner, 10: donor}[pk] + mock_device.objects.select_for_update.return_value.filter.return_value.order_by.return_value = [ + locked_winner, + locked_donor, + ] + resp = view.post(request, device_id=99) + + assert resp.status_code == 200 + # Donor is the *other* merge candidate (pk=10), never the posted self-pk=20. + mock_merge.assert_called_once() + called_winner, called_donor = mock_merge.call_args[0][:2] + assert called_winner is locked_winner + assert called_donor is locked_donor From 725969c87b74cda16447a1acbdb76d0b92683a1f Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Sat, 13 Jun 2026 00:29:18 +0200 Subject: [PATCH 64/98] fix(oob): reject boolean object_id before the falsy check in IP verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bool is an int subclass, so object_id=False hit the 'No object ID provided' falsy branch and never reached the explicit boolean guard — inconsistent with the stated intent of rejecting JSON booleans up front. Move the isinstance bool check ahead of 'if not object_id' so False/True both return 'Invalid object ID'. --- .../tests/test_ip_verify.py | 20 +++++++++++++++++++ .../views/base/ip_addresses_view.py | 10 ++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/netbox_librenms_plugin/tests/test_ip_verify.py b/netbox_librenms_plugin/tests/test_ip_verify.py index b2fb8ec31..485dd4f67 100644 --- a/netbox_librenms_plugin/tests/test_ip_verify.py +++ b/netbox_librenms_plugin/tests/test_ip_verify.py @@ -159,3 +159,23 @@ def test_non_numeric_vrf_id_returns_400(self): payload = json.loads(response.content) assert payload["status"] == "error" assert payload["message"] == "Invalid VRF ID" + + def test_boolean_false_object_id_rejected_as_invalid(self): + # bool is an int subclass; object_id=False must hit the explicit boolean guard + # ("Invalid object ID"), not the falsy "No object ID provided" branch. The guard + # therefore has to run before `if not object_id`. + view = _make_view() + request = _make_request({"device_id": False, "ip_address": "10.0.0.1/24", "object_type": "device"}) + response = view.post(request) + assert response.status_code == 400 + payload = json.loads(response.content) + assert payload["message"] == "Invalid object ID" + + def test_boolean_true_object_id_rejected_as_invalid(self): + # object_id=True would otherwise int() to 1 and validate as device #1. + view = _make_view() + request = _make_request({"device_id": True, "ip_address": "10.0.0.1/24", "object_type": "device"}) + response = view.post(request) + assert response.status_code == 400 + payload = json.loads(response.content) + assert payload["message"] == "Invalid object ID" diff --git a/netbox_librenms_plugin/views/base/ip_addresses_view.py b/netbox_librenms_plugin/views/base/ip_addresses_view.py index 7650fabb4..bce956257 100644 --- a/netbox_librenms_plugin/views/base/ip_addresses_view.py +++ b/netbox_librenms_plugin/views/base/ip_addresses_view.py @@ -539,15 +539,17 @@ def post(self, request): if not ip_address: return JsonResponse({"status": "error", "message": "No IP address provided"}, status=400) + # Reject JSON booleans explicitly before the falsy check: bool is an int + # subclass, so True/False would otherwise pass int() and validate as IDs 1/0, + # and object_id=False would be misreported as "No object ID provided". + if isinstance(object_id, bool): + return JsonResponse({"status": "error", "message": "Invalid object ID"}, status=400) + if not object_id: return JsonResponse({"status": "error", "message": "No object ID provided"}, status=400) # Validate the client-supplied numeric IDs up front so a bad value returns a # clean 400 instead of raising deep in the ORM and being caught as a generic 500. - # Reject JSON booleans explicitly: bool is an int subclass, so True/False would - # otherwise pass int() and validate as IDs 1/0. - if isinstance(object_id, bool): - return JsonResponse({"status": "error", "message": "Invalid object ID"}, status=400) try: object_id = int(object_id) except (TypeError, ValueError): From 46af91ecc445b196b7ad5f57f3b991d430beb0f1 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Sat, 13 Jun 2026 00:29:19 +0200 Subject: [PATCH 65/98] fix(oob): don't close the validation modal on Escape while a nested dialog is open The document-level Escape handler closed the outer #htmx-modal whenever it was shown, ignoring nested dialogs (e.g. the promote-to-host modal rendered inside validation flow. Bail out of the outer close when a '#htmx-modal-content .modal.show' is present so Bootstrap closes the topmost child instead. --- .../static/netbox_librenms_plugin/js/librenms_import.js | 6 ++++++ 1 file changed, 6 insertions(+) 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 8bcac3736..69ab02e12 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 @@ -1274,6 +1274,12 @@ // Handle Escape key to close modal document.addEventListener('keydown', function (event) { if (event.key === 'Escape') { + // A nested dialog (e.g. the promote-to-host modal rendered inside + // #htmx-modal-content) owns Escape while it is open — let Bootstrap close + // the topmost child, don't tear down the whole validation modal underneath it. + if (document.querySelector('#htmx-modal-content .modal.show')) { + return; + } if (modalElement?.classList.contains('show')) { hideModal(modalElement, fallbackBackdropRef); } From ff9c58a581fa19f1a196ee927455e8ee119f9e52 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Sat, 13 Jun 2026 10:44:23 +0200 Subject: [PATCH 66/98] fix(import): fail closed on ambiguous librenms_id, harden id input + OOB picker init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - device_operations: skip the hostname/serial/IP fallback when an ambiguous librenms_id was flagged, so it can't rebind existing_device and defeat the fail-closed contract (mirrors the librenms_id block guard). - find_by_librenms_id: reject floats and non-scalar objects before they reach _id_variants()/ORM predicates — only int/str honour the coerce_librenms_id int-only contract. - _oob_interface_select: sync the create-new input state once on load, not only on change, so it matches the rendered selection even when the suggested option is stale/missing. --- .../import_utils/device_operations.py | 7 +++-- .../htmx/_oob_interface_select.html | 11 +++++-- .../tests/test_coverage_actions.py | 10 +++++++ .../tests/test_coverage_device_operations.py | 30 +++++++++++++++++++ .../tests/test_librenms_id.py | 20 +++++++++++++ netbox_librenms_plugin/utils.py | 7 ++++- 6 files changed, 79 insertions(+), 6 deletions(-) diff --git a/netbox_librenms_plugin/import_utils/device_operations.py b/netbox_librenms_plugin/import_utils/device_operations.py index 39213165a..9be6dbec2 100644 --- a/netbox_librenms_plugin/import_utils/device_operations.py +++ b/netbox_librenms_plugin/import_utils/device_operations.py @@ -500,8 +500,11 @@ def validate_device_for_import( 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"]: + # Only check hostname/serial/IP if not already matched by librenms_id. + # Skip when an ambiguous librenms_id was flagged — hostname/serial/IP matching + # would otherwise rebind existing_device/existing_match_type and defeat the + # fail-closed ambiguity contract (mirrors the librenms_id block guard above). + if not result["existing_device"] and not result["ambiguous_librenms_id"]: # 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() diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_oob_interface_select.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_oob_interface_select.html index 16db3fb9d..0f73f34de 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_oob_interface_select.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_oob_interface_select.html @@ -40,11 +40,16 @@ if (!select) return; var container = document.getElementById("oob-new-iface-{{ libre_device.device_id }}"); var nameInput = document.getElementById("oob-new-iface-name-{{ libre_device.device_id }}"); - select.addEventListener("change", function() { - var creating = this.value === "__new__"; + function syncCreateState() { + var creating = select.value === "__new__"; container.style.display = creating ? "block" : "none"; nameInput.disabled = !creating; nameInput.required = creating; - }); + } + select.addEventListener("change", syncCreateState); + // Sync once on load so the input matches the rendered selection even if it + // differs from the server-side display logic (e.g. a stale/missing suggested + // option leaves a different option selected than the template assumed). + syncCreateState(); })(); diff --git a/netbox_librenms_plugin/tests/test_coverage_actions.py b/netbox_librenms_plugin/tests/test_coverage_actions.py index b7d1dd042..576069ad0 100644 --- a/netbox_librenms_plugin/tests/test_coverage_actions.py +++ b/netbox_librenms_plugin/tests/test_coverage_actions.py @@ -5583,6 +5583,16 @@ def test_wires_change_handler_via_script_block(self): # Targets this device's own select id (namespaced by device_id). assert 'getElementById("oob-iface-7")' in html + def test_initializes_create_state_on_load(self): + """The script must sync the "new name" input once on load (not only on change), so + the input matches the rendered selection even if it differs from the server-side + display logic (e.g. a stale/missing suggested option).""" + html = self._render() + assert "function syncCreateState()" in html + # Bound to change AND invoked immediately so initial state is authoritative. + assert 'addEventListener("change", syncCreateState)' in html + assert "syncCreateState();" in html + class TestMergeNetBoxDevicesViewDonorDerivation: """The merge view derives the donor from winner_pk + merge_candidates and diff --git a/netbox_librenms_plugin/tests/test_coverage_device_operations.py b/netbox_librenms_plugin/tests/test_coverage_device_operations.py index e4ffc4dc0..0dadae784 100644 --- a/netbox_librenms_plugin/tests/test_coverage_device_operations.py +++ b/netbox_librenms_plugin/tests/test_coverage_device_operations.py @@ -343,6 +343,36 @@ def test_vm_match_with_ambiguous_device_lookup_drops_vm_binding(self): assert result["existing_device"] is None assert result["existing_match_type"] != "librenms_id" + def test_ambiguous_librenms_id_blocks_hostname_rebind(self): + """When the librenms_id is ambiguous, the hostname/serial/IP fallback must NOT run + and rebind existing_device — even when a NetBox device shares the hostname, the + import has to stay fail-closed on the ambiguity rather than silently adopt a match.""" + from unittest.mock import MagicMock, patch + + from netbox_librenms_plugin.utils import AmbiguousLibreNMSIdError + + hostname_match = MagicMock() + hostname_match.name = "router01" + # A NetBox Device DOES exist with this hostname; the guard must ignore it. + mock_device = MagicMock() + mock_device.objects.filter.return_value.first.return_value = hostname_match + mock_device.objects.filter.return_value.exclude.return_value.first.return_value = None + + result = self._run_validate( + self._base_device(), + patches_overrides=[ + patch( + "netbox_librenms_plugin.import_utils.device_operations.find_by_librenms_id", + side_effect=AmbiguousLibreNMSIdError("dup host pk=1, pk=2"), + ), + patch("netbox_librenms_plugin.import_utils.device_operations.Device", mock_device), + ], + ) + assert result["ambiguous_librenms_id"] is True + # Fail-closed: the hostname match must NOT be adopted as the existing device. + assert result["existing_device"] is None + assert result["existing_match_type"] == "ambiguous_librenms_id" + def test_new_vm_without_cluster_is_not_ready(self): """New VM import with no cluster available must not be ready.""" result = self._run_validate(self._base_device(hostname="vm01"), import_as_vm=True) diff --git a/netbox_librenms_plugin/tests/test_librenms_id.py b/netbox_librenms_plugin/tests/test_librenms_id.py index c76d3a876..8535e65cb 100644 --- a/netbox_librenms_plugin/tests/test_librenms_id.py +++ b/netbox_librenms_plugin/tests/test_librenms_id.py @@ -311,6 +311,26 @@ def test_fail_closed_on_duplicate_oob_matches(self): with pytest.raises(AmbiguousLibreNMSIdError): find_by_librenms_id(mock_model, 42, "default") + def test_float_input_rejected_without_querying(self): + """A positive float bypasses the int-only coerce_librenms_id() contract, so it + must be rejected up front — never reaching the ORM predicates.""" + from unittest.mock import MagicMock + from netbox_librenms_plugin.utils import find_by_librenms_id + + mock_model = MagicMock() + assert find_by_librenms_id(mock_model, 42.0, "default") is None + mock_model.objects.filter.assert_not_called() + + def test_non_scalar_input_rejected_without_querying(self): + """Arbitrary non-int/str objects (e.g. a dict) must fail closed before the + lookup queries rather than being coerced into junk variants.""" + from unittest.mock import MagicMock + from netbox_librenms_plugin.utils import find_by_librenms_id + + mock_model = MagicMock() + assert find_by_librenms_id(mock_model, {"id": 42}, "default") is None + mock_model.objects.filter.assert_not_called() + class TestMigrateLegacyLibreNMSId: """Tests for migrate_legacy_librenms_id().""" diff --git a/netbox_librenms_plugin/utils.py b/netbox_librenms_plugin/utils.py index e0d64fe2d..7cfc1c69f 100644 --- a/netbox_librenms_plugin/utils.py +++ b/netbox_librenms_plugin/utils.py @@ -1154,7 +1154,12 @@ def find_by_librenms_id(model, librenms_id, server_key: str = "default"): return None if isinstance(librenms_id, bool): return None - if isinstance(librenms_id, (int, float)) and librenms_id <= 0: + # Reject floats and arbitrary non-scalar objects before they reach _id_variants() + # and the ORM predicates: only int/str representations honour the int-only contract + # enforced by coerce_librenms_id() (which a positive float would otherwise bypass). + if not isinstance(librenms_id, (int, str)): + return None + if isinstance(librenms_id, int) and librenms_id <= 0: return None if isinstance(librenms_id, str): cleaned = librenms_id.strip() From 785de4f710099e40cd8d99e93a9509570ee296aa Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Sat, 13 Jun 2026 13:39:42 +0200 Subject: [PATCH 67/98] fix(oob/import): harden migrated-marker, OOB id/module rows, inventory payloads, import validation - migrate: reject a _migrated_to marker that points back at the donor (self-merge). - set_librenms_oob: route a bare-int host id through coerce_librenms_id() so a stored 0/negative fails closed instead of wrapping into a bogus {"id": 0}. - modules _build_row: run the OOB short-circuit BEFORE the integrated-child check so a controller-fed row that matches an ancestor keeps its OOB status. - modules post(): treat a success flag with a non-list inventory payload (host and OOB) as a fetch failure instead of crashing the iterate/mutate. - bulk_import: clear the stale 'role must be selected' blocker when a fresh lookup resolves a row to an existing role-less device. - device_validation_details: carry the active server_key on the Full Sync link. - device_fields perm tests: assert against the real PlatformMapping, not a MagicMock. --- .../import_utils/bulk_import.py | 4 ++ .../htmx/device_validation_details.html | 4 +- .../tests/test_coverage_bulk_import.py | 36 +++++++++++ .../tests/test_coverage_device_fields.py | 19 +++--- .../tests/test_import_utils.py | 17 ++++++ .../tests/test_librenms_id.py | 15 +++++ .../tests/test_migrate_views.py | 16 +++++ .../tests/test_modules_view.py | 52 ++++++++++++++++ netbox_librenms_plugin/utils.py | 9 ++- .../views/base/modules_view.py | 59 ++++++++++--------- netbox_librenms_plugin/views/sync/migrate.py | 10 +++- 11 files changed, 201 insertions(+), 40 deletions(-) diff --git a/netbox_librenms_plugin/import_utils/bulk_import.py b/netbox_librenms_plugin/import_utils/bulk_import.py index 0c861badc..ca655d9db 100644 --- a/netbox_librenms_plugin/import_utils/bulk_import.py +++ b/netbox_librenms_plugin/import_utils/bulk_import.py @@ -627,6 +627,10 @@ def _lookup_in_model(m): "role": None, "available_roles": validation.get("device_role", {}).get("available_roles", []), } + # A previously-unmatched row may still carry the "Device role must be manually + # selected before import" blocker; clear it now that the row resolves to an + # existing object, so the stale message doesn't linger in the UI. + remove_validation_issue(validation, "role") recalculate_validation_status(validation, is_vm=actual_is_vm) # Re-assert non-importable: recalculate sets can_import from issues list, # but a late-found existing match must never be import-ready. 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 3ff23906c..4d0ad9549 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 @@ -1052,7 +1052,9 @@