Skip to content

Feat/oob sync#295

Open
marcinpsk wants to merge 95 commits into
bonzo81:developfrom
marcinpsk:feat/oob-sync
Open

Feat/oob sync#295
marcinpsk wants to merge 95 commits into
bonzo81:developfrom
marcinpsk:feat/oob-sync

Conversation

@marcinpsk

@marcinpsk marcinpsk commented May 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Builds on #303 (feat/ipam). Lets a single NetBox device represent a physical server while pulling data from two LibreNMS devices — the in-band host (the OS, polled via SNMP/agent on the production NIC) and the out-of-band management controller (iDRAC / iLO / IPMI / BMC, polled via its dedicated management IP). Sync makes it obvious which LibreNMS source each row came from, and pairing / merging an existing host+OOB pair (or promoting an already-imported OOB-only device to a full host) becomes an import action instead of manual cleanup.

Motivation / Problem

Feature.

Servers typically appear in LibreNMS as two separate devices: the in-band host and the out-of-band management controller. They share one physical chassis but expose different inventories, interfaces and IPs. NetBox models them as one device with an OOB IP and management interface — there is no separate iDRAC device. Today the plugin doesn't bridge that mismatch:

  • Importing both LibreNMS devices creates two NetBox devices, duplicating the chassis.
  • Importing only one side loses the data from the other (no OOB IP, or no production interfaces/inventory).
  • After-the-fact reconciliation is manual.

Goal: let one NetBox device represent the physical box and pull data from both LibreNMS devices:

  • Host-side → production interfaces, IPs, modules, cables
  • OOB-side → management interface + OOB IP, controller inventory, sensors

Would close #289.

Scope of Change

  • Sync/Import logic
  • LibreNMS API interaction
  • Web UI / templates
  • Tests

Highlights (delta over feat/ipam):

  • Pair host/OOB devices — mark the OOB LibreNMS counterpart on a NetBox device; both sides then sync into the one device.
  • Source-aware sync rows — interfaces, modules and cables carry a _source badge showing whether the row came from the host or the OOB device.
  • Merge / promote import UX — detect a host/OOB pair from the import page, merge two existing NetBox devices into one, or promote an OOB-only device to a full host.
  • Interface-assigned OOB IP — on attach/promote, the OOB IP is set from an IP assigned to a device interface (auto-picked from the LibreNMS OOB port, e.g. idrac0, with confirm/override). This satisfies NetBox's requirement that oob_ip be interface-assigned; no unassigned/global IP addresses are created.
  • Compact librenms_id linkage — the OOB sub-object stores only {id, type}; the controller's IP/version are read live from LibreNMS via the id rather than denormalised into the custom field.

How Was This Tested?

  • Unit tests: yes — added/updated coverage for collision detection, device-migration views, device operations, librenms_id coercion + OOB helpers, and import-action contracts (test_collisions.py, test_migrate_views.py, test_coverage_*, test_import_validation_helpers.py, test_librenms_id.py).
  • Manual testing: yes — full end-to-end walkthrough against a live LibreNMS + NetBox instance.

Manual Test Steps

  1. On a NetBox device, mark its OOB LibreNMS counterpart and confirm the two are paired for sync.
  2. Run interface / module / cable sync and verify each row shows the correct host vs. OOB _source badge.
  3. From the import page, import a host/OOB pair and confirm a single NetBox device is created with both production interfaces and the OOB IP on the chosen interface.
  4. Merge two pre-existing NetBox devices that represent the same physical box; confirm no duplicate chassis remains and references survive.
  5. Promote an already-imported OOB-only device to a full host; confirm the OOB linkage and the interface-assigned OOB IP.

Risk Assessment

  • Affects existing users: touches the import/sync code paths, but the new pairing / merge / promote behaviour is opt-in (driven by explicit user actions). Default sync/import flows for unpaired devices are unchanged.
  • Could cause unintended imports / updates: merge and promote are explicit, confirmed actions and never run automatically. The OOB IP is only set to an interface the user picks or confirms — no IP address is auto-created.

Backwards Compatibility

  • No breaking changes.
  • No new database migration in this PR — OOB linkage rides on the existing librenms_id custom field.

Other Notes

  • Stacked on feat: set primary IP action #303 (feat/ipam) — review that first; the diff here is best read as the delta over feat/ipam.
  • Worth a close look: the merge/promote import actions in views/imports/actions.py, device migration in views/sync/migrate.py, and the OOB linkage helpers in utils.py.

@coderabbitai

coderabbitai Bot commented May 25, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds Out‑of‑Band (OOB) detection and normalisation, extends import validation to surface OOB/promote/merge reconciliation actions (including merge candidates), implements bulk-import collision detection, introduces HTMX endpoints to add as OOB, promote to host, and merge devices, and provides Stage‑2b “move to winner” migrate actions. Sync views (interfaces, cables, modules, IPs, VLANs) are scoped to the selected LibreNMS server and merge OOB controller data (tagged via _source). Utilities support dict-form librenms_id, OOB helpers, merging/marker semantics. Templates, JS and tables render OOB badges and HTMX flows. Extensive tests and docs updated; URLs/export surfaces extended.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ValidationView
  participant ActionsView
  participant NetBoxDB
  participant LibreNMSAPI

  User->>ValidationView: open device validation
  ValidationView->>LibreNMSAPI: fetch device/os/hardware
  ValidationView->>NetBoxDB: find by librenms_id / serial / hostname
  ValidationView-->>User: show Add-as-OOB / Promote / Merge options

  User->>ActionsView: POST Add-as-OOB (device_id, oob_id, type, iface)
  ActionsView->>NetBoxDB: set librenms_id[{server}].oob and optionally attach OOB IP/interface
  ActionsView-->>User: HX-Trigger validationRefresh

  User->>ActionsView: POST Merge (winner, donor)
  ActionsView->>NetBoxDB: merge librenms links, mark donor._migrated_to
  ActionsView-->>User: HX-Refresh
Loading

Possibly related PRs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🧹 Nitpick comments (4)
docs/usage_tips/mapping_rules.md (2)

3-3: 💤 Low value

Consider adding a comma for clarity.

The sentence "with them you can cover vendor naming variations..." would read more clearly as "with them, you can cover vendor naming variations...".

✍️ Suggested improvement
-Mapping rules are the configuration layer that connects LibreNMS identifiers to NetBox objects. Without them the plugin relies on exact-string matching; with them you can cover vendor naming variations, OS string aliases, model-number differences, and bay naming schemes across your entire fleet.
+Mapping rules are the configuration layer that connects LibreNMS identifiers to NetBox objects. Without them the plugin relies on exact-string matching; with them, you can cover vendor naming variations, OS string aliases, model-number differences, and bay naming schemes across your entire fleet.

195-195: 💤 Low value

Consider adding article for clarity.

The phrase "to have bay to live in" would be clearer as "to have a bay to live in".

✍️ Suggested improvement
-Some chassis report child components (CPMs, MDAs, mezzanines) without the intermediate carrier/holder module that must first exist in NetBox for those children to have bay to live in. A Carrier Auto-Install Rule tells the plugin: "for this manufacturer / device type, when you see an orphan component of this class and name pattern, suggest installing this ModuleType into the matching empty bay."
+Some chassis report child components (CPMs, MDAs, mezzanines) without the intermediate carrier/holder module that must first exist in NetBox for those children to have a bay to live in. A Carrier Auto-Install Rule tells the plugin: "for this manufacturer / device type, when you see an orphan component of this class and name pattern, suggest installing this ModuleType into the matching empty bay."
netbox_librenms_plugin/tests/test_coverage_device_operations.py (1)

1692-1720: ⚡ Quick win

Patch VC detection in the shared OOB test fixture to keep these tests deterministic.

validate_device_for_import() will still execute VC detection in these tests (api is set, vc detection defaults on), which makes OOB assertions depend on unrelated logic. Add a default get_virtual_chassis_data patch in _base_patches() so these tests stay scoped and stable.

Proposed diff
     def _base_patches(self, mock_device_cls, mock_vm_cls=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.get_virtual_chassis_data",
+                return_value={"is_stack": False, "member_count": 0, "members": [], "detection_error": None},
+            ),
             patch(
                 "netbox_librenms_plugin.import_utils.device_operations.match_librenms_hardware_to_device_type",
                 return_value={"matched": False},
             ),
netbox_librenms_plugin/tests/test_migrate_views.py (1)

275-277: ⚡ Quick win

Tighten save-call assertions for partial-field writes.

On Line 276 and Line 277, assert_called_once() is too broad; this path should explicitly enforce update_fields=["oob_ip"] on both saves, matching the view’s concurrency/validation-safe contract.

Proposed test tightening
-        winner.save.assert_called_once()
-        donor.save.assert_called_once()
+        winner.save.assert_called_once_with(update_fields=["oob_ip"])
+        donor.save.assert_called_once_with(update_fields=["oob_ip"])

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 76d85bcd-7195-40c2-a3ac-ed4764a00939

📥 Commits

Reviewing files that changed from the base of the PR and between 3de5c61 and 6cf0eef.

⛔ Files ignored due to path filters (13)
  • docs/img/Netbox-librenms-plugin-device-sync-fields.png is excluded by !**/*.png
  • docs/img/Netbox-librenms-plugin-import-page.png is excluded by !**/*.png
  • docs/img/Netbox-librenms-plugin-module-sync-tab.png is excluded by !**/*.png
  • docs/img/carrier_auto_install_rules/list.png is excluded by !**/*.png
  • docs/img/device_type_mappings/list.png is excluded by !**/*.png
  • docs/img/inventory_ignore_rules/list.png is excluded by !**/*.png
  • docs/img/module_bay_mappings/list.png is excluded by !**/*.png
  • docs/img/module_type_mappings/add.png is excluded by !**/*.png
  • docs/img/module_type_mappings/list.png is excluded by !**/*.png
  • docs/img/normalization_rules/add.png is excluded by !**/*.png
  • docs/img/normalization_rules/list.png is excluded by !**/*.png
  • docs/img/platform_mappings/add.png is excluded by !**/*.png
  • docs/img/platform_mappings/list.png is excluded by !**/*.png
📒 Files selected for processing (49)
  • docs/README.md
  • docs/feature_list.md
  • docs/librenms_import/validation.md
  • docs/usage_tips/README.md
  • docs/usage_tips/mapping_rules.md
  • docs/usage_tips/module_sync.md
  • netbox_librenms_plugin/constants.py
  • netbox_librenms_plugin/forms.py
  • netbox_librenms_plugin/import_utils/__init__.py
  • netbox_librenms_plugin/import_utils/bulk_import.py
  • netbox_librenms_plugin/import_utils/collisions.py
  • netbox_librenms_plugin/import_utils/device_operations.py
  • netbox_librenms_plugin/import_utils/ip_helpers.py
  • netbox_librenms_plugin/import_utils/vm_operations.py
  • netbox_librenms_plugin/librenms_api.py
  • netbox_librenms_plugin/migrations/0011_librenmssettings_auto_create_ipam_default.py
  • netbox_librenms_plugin/models.py
  • netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js
  • netbox_librenms_plugin/tables/cables.py
  • netbox_librenms_plugin/tables/device_status.py
  • netbox_librenms_plugin/tables/interfaces.py
  • netbox_librenms_plugin/tables/modules.py
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_dt_mapping_form.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_platform_mapping_form.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/bulk_import_collision.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_import_row.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_import.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html
  • netbox_librenms_plugin/templates/netbox_librenms_plugin/settings.html
  • netbox_librenms_plugin/tests/test_collisions.py
  • netbox_librenms_plugin/tests/test_coverage_actions.py
  • netbox_librenms_plugin/tests/test_coverage_device_operations.py
  • netbox_librenms_plugin/tests/test_coverage_list.py
  • netbox_librenms_plugin/tests/test_import_utils.py
  • netbox_librenms_plugin/tests/test_ip_helpers.py
  • netbox_librenms_plugin/tests/test_librenms_id.py
  • netbox_librenms_plugin/tests/test_migrate_views.py
  • netbox_librenms_plugin/urls.py
  • netbox_librenms_plugin/utils.py
  • netbox_librenms_plugin/views/__init__.py
  • netbox_librenms_plugin/views/base/cables_view.py
  • netbox_librenms_plugin/views/base/interfaces_view.py
  • netbox_librenms_plugin/views/base/librenms_sync_view.py
  • netbox_librenms_plugin/views/base/modules_view.py
  • netbox_librenms_plugin/views/imports/actions.py
  • netbox_librenms_plugin/views/imports/list.py
  • netbox_librenms_plugin/views/sync/migrate.py

Comment thread netbox_librenms_plugin/import_utils/device_operations.py
Comment thread netbox_librenms_plugin/import_utils/device_operations.py Outdated
Comment thread netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js Outdated
Comment thread netbox_librenms_plugin/tables/device_status.py Outdated
Comment thread netbox_librenms_plugin/views/base/cables_view.py Outdated
Comment thread netbox_librenms_plugin/views/base/interfaces_view.py Outdated
Comment thread netbox_librenms_plugin/views/base/modules_view.py Outdated
Comment thread netbox_librenms_plugin/views/imports/actions.py Outdated
Comment thread netbox_librenms_plugin/views/imports/list.py Outdated
@marcinpsk

Copy link
Copy Markdown
Contributor Author

hi @bonzo81 - this probably could be split in 2 - as it has some UI improvements that could be separate - I will do that as I see CR has a lot to say on this one - will switch to draft for now. I'd expect to rebase that on top of your changes around module/interface sync, when that would be ready/in develop.

@marcinpsk marcinpsk marked this pull request as draft May 25, 2026 12:18
@bonzo81

bonzo81 commented May 25, 2026

Copy link
Copy Markdown
Owner

Thanks @marcinpsk sounds fair enough. CR certainly was busy! Just created PR #296 as a module sync follow up

marcinpsk added a commit to marcinpsk/netbox-librenms-plugin that referenced this pull request May 25, 2026
- utils.py: use _type_normalized in OOB_TYPE_PATTERN.search to avoid
  TypeError when oob_type is None
- device_status.py: coerce paired_oob_id/paired_host_id to int before
  comparison to handle mixed str/int values
- device_operations.py: add created_ips:[] to all failure return paths
  in import_single_device for consistent response schema
- cables_view.py: resolve OOB mapping from VC sync device instead of
  raw obj so VC pages load controller LLDP rows correctly
- interfaces_view.py: guard chassis_member None for OOB port names that
  don't resolve to a VC member position
- modules_view.py: use sync_device instead of obj when reading OOB
  mapping so VC pages find the mapping on the correct member
- actions.py: only re-check change permission for concurrent PlatformMapping
  creation when the row actually needs updating (not a no-op)
- list.py: replace inline auto_create_ipam fallback with
  resolve_auto_create_ipam() to honour request POST/GET toggle
- librenms_import.js: align Bootstrap global detection order in
  validationRefresh handler to match rest of plugin
- templates: log error.message in fetch catch blocks for diagnosability
- test_collisions.py: use token-based set assertion to avoid substring
  false positives on role strings
marcinpsk added a commit to marcinpsk/netbox-librenms-plugin that referenced this pull request May 25, 2026
- utils.py: use _type_normalized in OOB_TYPE_PATTERN.search to avoid
  TypeError when oob_type is None
- device_status.py: coerce paired_oob_id/paired_host_id to int before
  comparison to handle mixed str/int values
- device_operations.py: add created_ips:[] to all failure return paths
  in import_single_device for consistent response schema
- cables_view.py: resolve OOB mapping from VC sync device instead of
  raw obj so VC pages load controller LLDP rows correctly
- interfaces_view.py: guard chassis_member None for OOB port names that
  don't resolve to a VC member position
- modules_view.py: use sync_device instead of obj when reading OOB
  mapping so VC pages find the mapping on the correct member
- actions.py: only re-check change permission for concurrent PlatformMapping
  creation when the row actually needs updating (not a no-op)
- list.py: replace inline auto_create_ipam fallback with
  resolve_auto_create_ipam() to honour request POST/GET toggle
- librenms_import.js: align Bootstrap global detection order in
  validationRefresh handler to match rest of plugin
- templates: log error.message in fetch catch blocks for diagnosability
- test_collisions.py: use token-based set assertion to avoid substring
  false positives on role strings
marcinpsk added a commit to marcinpsk/netbox-librenms-plugin that referenced this pull request May 27, 2026
- utils.py: use _type_normalized in OOB_TYPE_PATTERN.search to avoid
  TypeError when oob_type is None
- device_status.py: coerce paired_oob_id/paired_host_id to int before
  comparison to handle mixed str/int values
- device_operations.py: add created_ips:[] to all failure return paths
  in import_single_device for consistent response schema
- cables_view.py: resolve OOB mapping from VC sync device instead of
  raw obj so VC pages load controller LLDP rows correctly
- interfaces_view.py: guard chassis_member None for OOB port names that
  don't resolve to a VC member position
- modules_view.py: use sync_device instead of obj when reading OOB
  mapping so VC pages find the mapping on the correct member
- actions.py: only re-check change permission for concurrent PlatformMapping
  creation when the row actually needs updating (not a no-op)
- list.py: replace inline auto_create_ipam fallback with
  resolve_auto_create_ipam() to honour request POST/GET toggle
- librenms_import.js: align Bootstrap global detection order in
  validationRefresh handler to match rest of plugin
- templates: log error.message in fetch catch blocks for diagnosability
- test_collisions.py: use token-based set assertion to avoid substring
  false positives on role strings
marcinpsk added a commit to marcinpsk/netbox-librenms-plugin that referenced this pull request May 29, 2026
- utils.py: use _type_normalized in OOB_TYPE_PATTERN.search to avoid
  TypeError when oob_type is None
- device_status.py: coerce paired_oob_id/paired_host_id to int before
  comparison to handle mixed str/int values
- device_operations.py: add created_ips:[] to all failure return paths
  in import_single_device for consistent response schema
- cables_view.py: resolve OOB mapping from VC sync device instead of
  raw obj so VC pages load controller LLDP rows correctly
- interfaces_view.py: guard chassis_member None for OOB port names that
  don't resolve to a VC member position
- modules_view.py: use sync_device instead of obj when reading OOB
  mapping so VC pages find the mapping on the correct member
- actions.py: only re-check change permission for concurrent PlatformMapping
  creation when the row actually needs updating (not a no-op)
- list.py: replace inline auto_create_ipam fallback with
  resolve_auto_create_ipam() to honour request POST/GET toggle
- librenms_import.js: align Bootstrap global detection order in
  validationRefresh handler to match rest of plugin
- templates: log error.message in fetch catch blocks for diagnosability
- test_collisions.py: use token-based set assertion to avoid substring
  false positives on role strings
@marcinpsk marcinpsk force-pushed the feat/oob-sync branch 5 times, most recently from 0521e1f to 7826081 Compare May 30, 2026 21:56
marcinpsk added a commit to marcinpsk/netbox-librenms-plugin that referenced this pull request May 31, 2026
- utils.py: use _type_normalized in OOB_TYPE_PATTERN.search to avoid
  TypeError when oob_type is None
- device_status.py: coerce paired_oob_id/paired_host_id to int before
  comparison to handle mixed str/int values
- device_operations.py: add created_ips:[] to all failure return paths
  in import_single_device for consistent response schema
- cables_view.py: resolve OOB mapping from VC sync device instead of
  raw obj so VC pages load controller LLDP rows correctly
- interfaces_view.py: guard chassis_member None for OOB port names that
  don't resolve to a VC member position
- modules_view.py: use sync_device instead of obj when reading OOB
  mapping so VC pages find the mapping on the correct member
- actions.py: only re-check change permission for concurrent PlatformMapping
  creation when the row actually needs updating (not a no-op)
- list.py: replace inline auto_create_ipam fallback with
  resolve_auto_create_ipam() to honour request POST/GET toggle
- librenms_import.js: align Bootstrap global detection order in
  validationRefresh handler to match rest of plugin
- templates: log error.message in fetch catch blocks for diagnosability
- test_collisions.py: use token-based set assertion to avoid substring
  false positives on role strings
marcinpsk added a commit to marcinpsk/netbox-librenms-plugin that referenced this pull request May 31, 2026
- utils.py: use _type_normalized in OOB_TYPE_PATTERN.search to avoid
  TypeError when oob_type is None
- device_status.py: coerce paired_oob_id/paired_host_id to int before
  comparison to handle mixed str/int values
- device_operations.py: add created_ips:[] to all failure return paths
  in import_single_device for consistent response schema
- cables_view.py: resolve OOB mapping from VC sync device instead of
  raw obj so VC pages load controller LLDP rows correctly
- interfaces_view.py: guard chassis_member None for OOB port names that
  don't resolve to a VC member position
- modules_view.py: use sync_device instead of obj when reading OOB
  mapping so VC pages find the mapping on the correct member
- actions.py: only re-check change permission for concurrent PlatformMapping
  creation when the row actually needs updating (not a no-op)
- list.py: replace inline auto_create_ipam fallback with
  resolve_auto_create_ipam() to honour request POST/GET toggle
- librenms_import.js: align Bootstrap global detection order in
  validationRefresh handler to match rest of plugin
- templates: log error.message in fetch catch blocks for diagnosability
- test_collisions.py: use token-based set assertion to avoid substring
  false positives on role strings
marcinpsk added a commit to marcinpsk/netbox-librenms-plugin that referenced this pull request Jun 1, 2026
- utils.py: use _type_normalized in OOB_TYPE_PATTERN.search to avoid
  TypeError when oob_type is None
- device_status.py: coerce paired_oob_id/paired_host_id to int before
  comparison to handle mixed str/int values
- device_operations.py: add created_ips:[] to all failure return paths
  in import_single_device for consistent response schema
- cables_view.py: resolve OOB mapping from VC sync device instead of
  raw obj so VC pages load controller LLDP rows correctly
- interfaces_view.py: guard chassis_member None for OOB port names that
  don't resolve to a VC member position
- modules_view.py: use sync_device instead of obj when reading OOB
  mapping so VC pages find the mapping on the correct member
- actions.py: only re-check change permission for concurrent PlatformMapping
  creation when the row actually needs updating (not a no-op)
- list.py: replace inline auto_create_ipam fallback with
  resolve_auto_create_ipam() to honour request POST/GET toggle
- librenms_import.js: align Bootstrap global detection order in
  validationRefresh handler to match rest of plugin
- templates: log error.message in fetch catch blocks for diagnosability
- test_collisions.py: use token-based set assertion to avoid substring
  false positives on role strings
marcinpsk added a commit to marcinpsk/netbox-librenms-plugin that referenced this pull request Jun 1, 2026
- utils.py: use _type_normalized in OOB_TYPE_PATTERN.search to avoid
  TypeError when oob_type is None
- device_status.py: coerce paired_oob_id/paired_host_id to int before
  comparison to handle mixed str/int values
- device_operations.py: add created_ips:[] to all failure return paths
  in import_single_device for consistent response schema
- cables_view.py: resolve OOB mapping from VC sync device instead of
  raw obj so VC pages load controller LLDP rows correctly
- interfaces_view.py: guard chassis_member None for OOB port names that
  don't resolve to a VC member position
- modules_view.py: use sync_device instead of obj when reading OOB
  mapping so VC pages find the mapping on the correct member
- actions.py: only re-check change permission for concurrent PlatformMapping
  creation when the row actually needs updating (not a no-op)
- list.py: replace inline auto_create_ipam fallback with
  resolve_auto_create_ipam() to honour request POST/GET toggle
- librenms_import.js: align Bootstrap global detection order in
  validationRefresh handler to match rest of plugin
- templates: log error.message in fetch catch blocks for diagnosability
- test_collisions.py: use token-based set assertion to avoid substring
  false positives on role strings
marcinpsk added a commit to marcinpsk/netbox-librenms-plugin that referenced this pull request Jun 1, 2026
- utils.py: use _type_normalized in OOB_TYPE_PATTERN.search to avoid
  TypeError when oob_type is None
- device_status.py: coerce paired_oob_id/paired_host_id to int before
  comparison to handle mixed str/int values
- device_operations.py: add created_ips:[] to all failure return paths
  in import_single_device for consistent response schema
- cables_view.py: resolve OOB mapping from VC sync device instead of
  raw obj so VC pages load controller LLDP rows correctly
- interfaces_view.py: guard chassis_member None for OOB port names that
  don't resolve to a VC member position
- modules_view.py: use sync_device instead of obj when reading OOB
  mapping so VC pages find the mapping on the correct member
- actions.py: only re-check change permission for concurrent PlatformMapping
  creation when the row actually needs updating (not a no-op)
- list.py: replace inline auto_create_ipam fallback with
  resolve_auto_create_ipam() to honour request POST/GET toggle
- librenms_import.js: align Bootstrap global detection order in
  validationRefresh handler to match rest of plugin
- templates: log error.message in fetch catch blocks for diagnosability
- test_collisions.py: use token-based set assertion to avoid substring
  false positives on role strings
@marcinpsk marcinpsk force-pushed the feat/oob-sync branch 3 times, most recently from 9161826 to 0cc8fea Compare June 1, 2026 13:27
marcinpsk added 20 commits June 13, 2026 13:39
…y 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.
…sceivers

Bulk install (_install_single) re-resolves each item's bay from scratch. For an
item with no traceable installed parent it scoped to top-level device bays only,
so a synthetic transceiver rendered as a top-level row whose 'Transceiver N/M' bay
is module-scoped (under an installed line card) was skipped as 'no matching bay' —
even though the table matched it (it matches against the combined all_bays) and a
single install (which trusts the table's resolved bay) succeeded.

Extract _candidate_bays_for_item: when no parent module is traced, match against
the same combined device + module-scoped bay set the table uses (device bays
winning on a name collision), so bulk install is consistent with the table and
single install. Parent-scoped resolution is unchanged.
…ind on adoption error

- modules_view: reject inventory lists with non-dict entries (main + OOB) so a
  malformed LibreNMS payload ([None], ["bad"]) is treated as a fetch failure
  instead of 500-ing the iterate/mutate loop.
- sync/modules: isolate template adoption from the primary interface bind so an
  adoption exception no longer clobbers an already-committed bind into a generic
  'failed'; surface the adoption error as a warning instead.
- test_modules_view: assert the exact cache key deleted on the inventory-failure
  path.
- Tests for all three paths.
…ad, log suppressed bind/adopt errors

- modules_view: keep the OOB-inventory-failure toast generic (ids stay in the
  server log, matching the ports/transceiver toasts).
- modules_view: treat a get_ports() dict whose 'ports' is missing/None or has
  non-dict entries as a fetch failure, so a degraded port-id-enrichment snapshot
  is no longer cached as complete (warns instead).
- sync/modules: add a module logger and logger.exception() in the isolated
  bind/adoption except blocks so suppressed tracebacks are captured server-side
  (the toast promises 'see server logs for details').
- Tests for all three.
…'t fake bind success

- ip_addresses_view: _resolve_management_ip() only strips a str ip (a {"ip": 123}
  payload no longer 500s the fresh refresh); validate get_device_ips() returns a
  list of dicts before enriching, else treat as a fetch failure (no empty snapshot
  cached under a success banner).
- modules_view: _merge_transceiver_data() rejects a non-list / non-dict-entry
  transceiver payload as an error instead of crashing on txr.get(...).
- sync/modules: distinguish 'no primary to bind' from 'bind never attempted'
  (bind_item present but server_key blank) so the latter reports a failure instead
  of falling into the adoption-only success path.
- Tests for all four.
…ing iface, reset stale VM cluster

- sync/modules: _modules_redirect_response() now propagates the active server_key
  (read from the request when not passed) so server-scoped module actions return
  to the same server tab; all inline ?tab=modules redirects route through it.
- migrate: _reconcile_donor_device_ip_fks() locks the owning Interface and re-reads
  device_id from the locked row, so a concurrent interface move can't leave the
  winner owning an address that has moved off it (TOCTOU).
- import_utils/bulk_import: a dropped cached VM match now resets the stale cluster
  selection (preserving available_clusters), mirroring the device_role reset.
- test_coverage_device_fields: assert the platform is assigned + saved in the
  mapping-permission-denied branch.
- Tests for each; updated the stale deleted-VM readiness test to the corrected contract.
…nt save order; guard server_key in redirects

- migrate: _reconcile_donor_device_ip_fks() now clears the donor FK before the winner
  claims the address. device.primary_ip4/6/oob_ip are UNIQUE per address, so the old
  winner-first save violated the constraint whenever a transfer actually ran — surfaced
  by converting the test off mocks to real NetBox models.
- test_migrate_views: replace the mock-based reconcile tests with real Device/Interface/
  IPAddress objects (transfer + foreign-interface skip), exercising the lock + FK move
  end-to-end.
- test_server_key_in_redirects: new guard asserting every '?tab=' URL under views/sync,
  views/base, views/object_sync propagates server_key — a recurring review finding, now
  locked in one place.
Rewrite TestRefreshExistingDevice against real NetBox models (Device,
VirtualMachine, Cluster, Interface, IPAddress, real librenms_id custom
fields) instead of MagicMock stand-ins. The refresh re-queries the DB to
detect changes since caching (matched object deleted, librenms link
removed, fresh match by name/serial/IP), so the conversion exercises the
real ORM lookups, linkage re-derivation, role/cluster reset and readiness
recompute end to end.

This surfaced that the prior neutralized-link test only asserted
can_import=True because its validation dict omitted site/device_type:
recalculate_validation_status raised KeyError, the production except
swallowed it, and the function returned before the fresh-lookup
re-evaluation ran. With a correctly shaped dict the re-evaluation runs and
re-blocks the row on the new-import role requirement; the test now asserts
that real contract.

Three cases stay as focused mocks with justification: the NOT-NULL
Device.role defensive branch (unconstructable with a real Device) and two
forced-exception logging paths (a real query can't be made to fail on
demand).
Rewrite TestGetMigratedToMarker, TestBuildMigratedContext and
TestResolveWinnerForDonor against real NetBox Devices with real
librenms_id custom fields instead of MagicMock donors and patched
Device querysets. get_migrated_to_marker reads the marker through the
real device.cf accessor; build_migrated_context / _resolve_winner_for_donor
resolve the winner via real Device.objects lookups (including the
self-pointing and winner-deleted cases via real pk reuse/deletion).

Removes the per-test MagicMock/queryset plumbing in favor of a shared
_make_migrate_device helper, exercising the actual CF read and ORM
resolution end to end.
Add dependency-free builder helpers to tests/conftest.py (make_device,
make_vm, make_cluster, make_serial_device, cable_together, ip_on,
delete_keeping_pk) and refactor the DB-backed conversions to use them,
removing the per-file Site/Manufacturer/DeviceType/DeviceRole quartets that
each test module was hand-rolling.

No new test dependency (factory_boy was considered but not adopted): the
builders are plain functions using get_or_create for shared infra, called
from @pytest.mark.django_db tests. The mock-based local _make_device in
TestProcessDeviceFilters is intentionally left untouched (it builds a
MagicMock for orchestration tests, not a real row).
Rewrite TestSuggestOOBInterface, TestResolveOOBInterface and TestAttachOOBIp
against real Device / Interface / IPAddress instead of patched model
querysets. This exercises the actual behaviour the helpers exist for: the
OOB-name interface scan, the select_for_update (device, name) reuse lookup,
real Interface creation + full_clean validation (a 500-char name now fails
real validation, not a mocked side_effect), and the address__net_host IP
lookup with its ownership / ambiguity / re-home / slash-32-create branches.

request.user.has_perm stays mocked — auth is an external boundary; the mock
request grants perms by default and the denial tests deny a single perm to
pin the specific check. The one select_for_update call-assertion test stays a
focused mock (a concurrency primitive a single-threaded real test can't
observe). Adds make_interface / make_ip to the shared conftest builders.
Rewrite TestSyncIPAddressesViewInterfaceResolution against real Device /
Interface / IPAddress. _build_interface_maps now indexes real interfaces
carrying real librenms_id port-id custom fields (the duplicate-port-id
ambiguity is exercised by two real interfaces sharing a stored id), and
the stale-interface_url regression runs the real process_ip_sync flow:
a real IPAddress is created and bound to the re-resolved interface, rather
than asserting kwargs on a patched IPAddress manager.

Note: set_librenms_device_id only mutates custom_field_data (callers
persist), so the tests save the interface before _build_interface_maps
re-queries it.
…OneToOne constraint

The merge view's oob_ip transfer set winner.oob_ip = donor.oob_ip then saved the
winner before the donor. With both rows momentarily pointing at the same IP, the
unique constraint on Device.oob_ip is violated. Save the donor first (releasing the
IP) before the winner claims it.

Convert the two merge-view POST classes to real DB: the transfer is now proven by
what persists and reloads (catching the constraint bug a MagicMock save hid), and
the donor-derivation role is proven by which device gets the persisted _migrated_to
marker. Only the LibreNMS validation pipeline, auth gates, and row HTML render stay mocked.
The nine DB-driven AddAsOOBView.post tests now run against real Devices: the
concurrency guards (get_librenms_oob / get_librenms_device_id / find_by_librenms_id)
and the set_librenms_oob linkage execute for real and the result is reloaded from
the DB, so a guard reading the wrong custom-field shape can no longer pass on a
synthesized MagicMock. Only the LibreNMS validation pipeline, auth gates, and the
row HTML render stay mocked. The early-return input guards and the forced
save-error rollback-wiring test remain unit tests (no DB state to exercise /
forced-error boundary).
The two detector-dependent VCNormalizationReportView paths stubbed
detect_vc_normalization_noop's return, so the view's integration with the real
detector + report builder was never exercised. Drive them against a real VC member
with a real Module: a NetBox InterfaceTemplate whose name carries no {module}/
{vc_position} token instantiates verbatim, so a Cisco-shaped name (matches the regex
→ real detector returns None → 400) and a Nokia-shaped name (no match → real
diagnostic → real build_vc_normalization_report rendered through the view) are both
produced for real. The early-return module_id guards stay unit tests.

Leaves TestVCNormalizationE2E as-is: it feeds controlled instantiated names into the
real regex/rewrite/detector, so its mocks already sit at the NetBox-ORM boundary and
our logic runs real — a full-DB rewrite would mostly exercise NetBox's instantiate().
… migrate tests

Address CodeRabbit findings on PR #79:

- ip_addresses_view._prepare_context: the fresh-refresh guard validated only the
  container shape, so a dict row missing the address/prefix or port_id fields
  _create_base_ip_entry() reads would KeyError mid-enrichment and 500. Validate the
  per-row schema and fail closed. Regression test added (fails pre-fix, passes post).
- test_server_key_in_redirects: emit offender paths relative to the views package
  instead of the bare basename, so a failure is unambiguous when two scoped files
  share a name.
- test_migrate_views: skip-path test now also asserts the donor keeps its IP
  (donor.primary_ip4_id == ip.pk), and the IP-move happy path is driven against real
  DB so the reassignment landing on the winner's same-named interface proves the
  donor re-lock and winner lookup used the correct device/name.
…s_data

A malformed LibreNMS/cache ports payload (rows that are strings or null)
caused port.get() to raise AttributeError and 500 the cables refresh, on
both the main-device and OOB ports loops. Guard each row with
isinstance(port, dict) (and the main loop's ports container with a list
check, mirroring the OOB branch). Regression tests feed malformed rows and
assert the valid row still resolves.
In migrated mode the VLAN sync form is replaced by a plain <div> so a migrated
donor cannot POST. The CSRF token and POST-only hidden inputs (server_key,
action) were still rendered inside that div — inert dead markup. Gate them on
{% if not migrated_to_marker %} so they only render with the real form. Adds a
render test asserting the migrated div emits no form/CSRF/hidden inputs while
normal mode still does.
…te path

The docstring claimed the OOB-ports-fetch-failure path drops the partial cache
entry and renders via fresh_data. That path was reworked to cache the host-only
snapshot tagged oob_incomplete=True and render from cache (banner surfaces the
missing OOB rows); no caller passes fresh_data anymore. Describe fresh_data as
the render-from-snapshot escape hatch it is and stop describing removed behavior.
Documentation-only; no behavior change.
The .playwright-mcp/ directory (page snapshots + console logs written by the
Playwright MCP server during local UI verification) was committed by accident.
Remove it from tracking and add it to .gitignore so these local artifacts no
longer land in the repo.
The merge-candidate detection paired the hostname-matched device with a serial peer
via .first(). Serial is not unique in NetBox (and device names are only unique per
site), so .first() could pick an arbitrary same-serial row and surface the wrong
merge target. Fetch up to two peers and only pair on exactly one; with more than one,
skip the suggestion and warn. Applied symmetrically to the serial-matched/hostname-peer
branch. DB-backed tests cover the multi-peer (warn, no suggestion) and unique-peer cases.
The three migrate/transfer reject-path tests stopped at the response assertion, so a
regression that partially mutated the interface/IP/FK and then returned the same error
toast would still pass. Add no-mutation guards: the collision reject keeps the interface
on the donor and saves nothing; the winner-already-has-field reject leaves donor.primary_ip4
intact and saves neither device; the winner-lacks-interface reject leaves the IP on the
donor interface and never persists it.
The _sync_redirect server_key tests covered allowlisted/spoofed/fallback handling but
never the url_has_allowed_host_and_scheme=False path that _sync_url already pins. Add a
test that an allowlisted server_key is still dropped when the redirect URL fails
validation, so a regression reflecting a known key into a rejected URL is caught.
… return

The interface refresh POST returned early on a missing librenms_id before the cache
invalidation ran, so a failed refresh on a previously-synced device left the old ports
snapshot in cache — the redirected sync tab (and downstream sync actions that load via
CacheMixin.get_cache_key) would then serve/consume stale interface data. Move the
cache.delete() calls ahead of the librenms_id lookup; they only depend on the
already-resolved lookup_device/server_key. Regression test asserts the snapshot is
cleared on the missing-id path (fails against the old ordering).
…-vals server_key

The interface table still renders interactive relationship/VC-member dropdowns in
migrated mode, and their verify-interface POST reads the token via
document.querySelector('[name=csrfmiddlewaretoken]'). Dropping the POST form in
migrated mode removed the only guaranteed token on the tab, so those JS requests hit
a null token (TypeError/403). Emit a standalone hidden csrfmiddlewaretoken in the
migrated <div> (a bare input never auto-submits, so it doesn't reintroduce the
live-form problem the form-drop avoided).

Also escapejs the server_key embedded in the migrated-mode hx-vals JSON so a key with
quotes/backslashes can't break JSON parsing and block the transfer/move action.

Adds a DB-backed render test asserting the token survives in migrated mode while the
form and form-only server_key input do not (and the form path still emits all three).
- migrate reject-path tests now assert the _fail() HTMX contract (HX-Reswap:none and
  no HX-Refresh) so a regression that returned HX-Refresh=true — refreshing as if the
  move/transfer succeeded — is caught.
- _attach_messages_oob render-error test captures the storage and asserts used is False,
  pinning that a render failure doesn't leave queued messages consumed.
- refresh-existing-device log test checks all positional args for the pk instead of only
  the format string, so a switch to parameterized logging stays green when correct.
…payloads, and non-Redis caches

- cables: an OOB-only sync device has no host LibreNMS id, so the host LLDP fetch fails.
  Don't return None on that failure — fall through so the OOB merge runs and OOB-only
  devices still render their cable rows (returns None only when nothing at all is found).
- interfaces: validate the OOB get_ports() payload (dict with a list of dict rows) before
  enriching/merging; a malformed-but-truthy response now follows the host-only oob_incomplete
  warning path instead of 500-ing.
- interfaces + vlans: guard cache.ttl() with getattr — ttl() is Redis-specific and not part
  of the Django cache API, so other backends raised AttributeError at render (mirrors
  ip_addresses_view / modules_view).
- vlans: evict the server-scoped VLAN snapshot on both refresh-failure exits so a failed
  refresh after a prior success can't leave a stale table for the next GET to render.
The outer-modal dismiss guard correctly stops nested data-bs-dismiss buttons from closing
the outer HTMX modal, but when Bootstrap's modal JS is unavailable those nested buttons did
nothing, leaving the user stuck in the child dialog. Manually hide just the nested modal in
that fallback (Bootstrap still handles it natively when present).
…unique-peer merge

- merge-peer discovery slices the queryset (exclude(...)[:2]); update the two skip-case mocks
  to stub the slice (__getitem__) instead of the unused .first() so the intended branch runs.
- the single-serial-peer test only asserted absence of the multi-peer warning, which passes
  even with merge detection disabled. Give the peer a LibreNMS link (so the conservative guard
  fires) and assert the positive outcome: serial_action == merge_netbox_devices + candidates.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: OOB sync — unify iDRAC/BMC and host-side data on a single NetBox device

3 participants