From 00925c3743bc16e9bf4063d121adbac0dcda9ba7 Mon Sep 17 00:00:00 2001 From: "Zach Biles @bile0026" Date: Sun, 19 Jan 2025 11:36:01 -0600 Subject: [PATCH 01/22] =?UTF-8?q?feat:=20=E2=9C=A8=20added=20nautobot=20to?= =?UTF-8?q?=20librenms=20device=20and=20location=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../librenms/diffsync/adapters/librenms.py | 4 +- .../librenms/diffsync/adapters/nautobot.py | 9 ++- .../librenms/diffsync/models/base.py | 1 + .../librenms/diffsync/models/librenms.py | 51 +++++++++++++++-- .../librenms/diffsync/models/nautobot.py | 8 ++- nautobot_ssot/integrations/librenms/jobs.py | 55 +++++++++++++++++-- .../integrations/librenms/utils/__init__.py | 9 +++ .../integrations/librenms/utils/librenms.py | 42 ++++++++++++-- 8 files changed, 159 insertions(+), 20 deletions(-) diff --git a/nautobot_ssot/integrations/librenms/diffsync/adapters/librenms.py b/nautobot_ssot/integrations/librenms/diffsync/adapters/librenms.py index ff8edca8..55921a08 100644 --- a/nautobot_ssot/integrations/librenms/diffsync/adapters/librenms.py +++ b/nautobot_ssot/integrations/librenms/diffsync/adapters/librenms.py @@ -18,6 +18,7 @@ ) from nautobot_ssot.integrations.librenms.utils import ( normalize_gps_coordinates, + normalize_device_hostname, ) from nautobot_ssot.integrations.librenms.utils.librenms import LibreNMSApi @@ -81,7 +82,7 @@ def load_device(self, device: dict): else: _status = librenms_status_map[device["status"]] new_device = self.device( - name=device[self.hostname_field], + name=normalize_device_hostname(device[self.hostname_field]), device_id=device["device_id"], location=(device["location"] if device["location"] is not None else "Unknown"), role=device["type"] if device["type"] is not None else None, @@ -95,6 +96,7 @@ def load_device(self, device: dict): device_type=(device["hardware"] if device["hardware"] is not None else "Unknown"), platform=device["os"] if device["os"] is not None else "Unknown", os_version=(device["version"] if device["version"] is not None else "Unknown"), + ip_address=device["ip"], system_of_record=os.getenv("NAUTOBOT_SSOT_LIBRENMS_SYSTEM_OF_RECORD", "LibreNMS"), ) self.add(new_device) diff --git a/nautobot_ssot/integrations/librenms/diffsync/adapters/nautobot.py b/nautobot_ssot/integrations/librenms/diffsync/adapters/nautobot.py index 07820283..83047663 100644 --- a/nautobot_ssot/integrations/librenms/diffsync/adapters/nautobot.py +++ b/nautobot_ssot/integrations/librenms/diffsync/adapters/nautobot.py @@ -13,7 +13,7 @@ NautobotDevice, NautobotLocation, ) -from nautobot_ssot.integrations.librenms.utils import check_sor_field, get_sor_field_nautobot_object +from nautobot_ssot.integrations.librenms.utils import check_sor_field, get_sor_field_nautobot_object, normalize_device_hostname class NautobotAdapter(DiffSync): @@ -80,11 +80,15 @@ def load_device(self): _software_version = nb_device.software_version.version except AttributeError: _software_version = None + try: + _ip_address = nb_device.primary_ip.host + except AttributeError: + _ip_address = None _device_id = None if nb_device.custom_field_data.get("librenms_device_id"): _device_id = nb_device.custom_field_data.get("librenms_device_id") new_device = NautobotDevice( - name=nb_device.name, + name=normalize_device_hostname(nb_device.name), device_id=_device_id, location=nb_device.location.name, status=nb_device.status.name, @@ -94,6 +98,7 @@ def load_device(self): platform=nb_device.platform.name, os_version=_software_version, serial_no=nb_device.serial, + ip_address=_ip_address, system_of_record=get_sor_field_nautobot_object(nb_device), uuid=nb_device.id, ) diff --git a/nautobot_ssot/integrations/librenms/diffsync/models/base.py b/nautobot_ssot/integrations/librenms/diffsync/models/base.py index 18ff3d5d..304e7fc4 100644 --- a/nautobot_ssot/integrations/librenms/diffsync/models/base.py +++ b/nautobot_ssot/integrations/librenms/diffsync/models/base.py @@ -61,6 +61,7 @@ class Device(DiffSyncModel): tenant: Optional[str] = None status: str device_type: str + ip_address: Optional[str] = None role: Optional[str] = None manufacturer: str platform: Optional[str] = None diff --git a/nautobot_ssot/integrations/librenms/diffsync/models/librenms.py b/nautobot_ssot/integrations/librenms/diffsync/models/librenms.py index b6d2873f..4f31ff72 100644 --- a/nautobot_ssot/integrations/librenms/diffsync/models/librenms.py +++ b/nautobot_ssot/integrations/librenms/diffsync/models/librenms.py @@ -7,12 +7,33 @@ class LibrenmsLocation(Location): """LibreNMS implementation of Location DiffSync model.""" @classmethod - def create(cls, diffsync, ids, attrs): + def create(cls, adapter, ids, attrs): """Create Location in LibreNMS from LibrenmsLocation object.""" - return super().create(diffsync=diffsync, ids=ids, attrs=attrs) + if attrs["status"] == "Active" or attrs["status"] == "Staged": + if attrs["latitude"] and attrs["longitude"]: + location = { + "location": ids["name"], + "lat": attrs["latitude"], + "lng": attrs["longitude"], + } + adapter.job.logger.info(f"Creating location in LibreNMS: {location['location']}") + adapter.lnms_api.create_librenms_location(location) + else: + adapter.job.logger.warning(f"Skipping location in LibreNMS: {ids['name']}. Latitude or Longitude is not set, which LibreNMS requires.") + else: + if adapter.job.debug: + adapter.job.logger.debug(f"Skipping location in LibreNMS: {ids['name']}. Status is not Active or Staged.") + return super().create(adapter=adapter, ids=ids, attrs=attrs) def update(self, attrs): """Update Location in LibreNMS from LibrenmsLocation object.""" + if "latitude" in attrs or "longitude" in attrs: + location = { + "lat": attrs["latitude"], + "lng": attrs["longitude"], + } + self.adapter.job.logger.info(f"Updating location in LibreNMS: {location}") + self.adapter.lnms_api.update_librenms_location(location) return super().update(attrs) def delete(self): @@ -21,12 +42,32 @@ def delete(self): class LibrenmsDevice(Device): - """LibreNMS implementation of Device DiffSync model.""" + """LibreNMS implementation of Device adapter model.""" @classmethod - def create(cls, diffsync, ids, attrs): + def create(cls, adapter, ids, attrs): """Create Device in LibreNMS from LibrenmsDevice object.""" - return super().create(diffsync=diffsync, ids=ids, attrs=attrs) + if attrs["status"] == "Active" or attrs["status"] == "Staged": + # Access the nested device dictionary correctly + device_data = adapter.job.source_adapter.dict()["device"][ids["name"]] + if device_data.get("ip_address"): + device = { + "hostname": device_data["ip_address"], + "display": ids["name"], + "location": attrs["location"], + } + if adapter.job.force_add: + device["force_add"] = True + if adapter.job.ping_fallback: + device["ping_fallback"] = True + adapter.job.logger.info(f"Creating device in LibreNMS: {device['hostname']}") + adapter.lnms_api.create_librenms_device(device) + else: + if adapter.job.debug: + adapter.job.logger.debug(f"Skipping device in LibreNMS: {ids['name']}. No Primary IP address found.") + else: + adapter.job.logger.info(f"Skipping device in LibreNMS: {ids['name']}. Status is not Active or Staged.") + return super().create(adapter=adapter, ids=ids, attrs=attrs) def update(self, attrs): """Update Device in LibreNMS from LibrenmsDevice object.""" diff --git a/nautobot_ssot/integrations/librenms/diffsync/models/nautobot.py b/nautobot_ssot/integrations/librenms/diffsync/models/nautobot.py index f7a7c450..7ad96661 100644 --- a/nautobot_ssot/integrations/librenms/diffsync/models/nautobot.py +++ b/nautobot_ssot/integrations/librenms/diffsync/models/nautobot.py @@ -67,12 +67,18 @@ def create(cls, adapter, ids, attrs): if adapter.job.debug: adapter.job.logger.debug(f'Creating Nautobot Location {ids["name"]}') + try: + _location_type = LocationType.objects.get(id=adapter.job.location_type.id) + except LocationType.DoesNotExist: + adapter.job.logger.warning(f"Location Type {adapter.job.location_type} does not exist. Using default Site Location Type.") + _location_type = LocationType.objects.get(name="Site") + new_location = ORMLocation( name=ids["name"], latitude=attrs["latitude"], longitude=attrs["longitude"], status=Status.objects.get(name=attrs["status"]), - location_type=LocationType.objects.get(name="Site"), + location_type=_location_type, ) if adapter.tenant: new_location.tenant = adapter.tenant diff --git a/nautobot_ssot/integrations/librenms/jobs.py b/nautobot_ssot/integrations/librenms/jobs.py index 189b4f98..219963f1 100644 --- a/nautobot_ssot/integrations/librenms/jobs.py +++ b/nautobot_ssot/integrations/librenms/jobs.py @@ -10,6 +10,7 @@ SecretsGroupSecretTypeChoices, ) from nautobot.extras.models import ExternalIntegration +from nautobot.dcim.models import LocationType from nautobot.tenancy.models import Tenant from nautobot_ssot.integrations.librenms.diffsync.adapters import librenms, nautobot @@ -49,10 +50,18 @@ class LibrenmsDataSource(DataSource): default="api", ) sync_locations = BooleanVar(description="Whether to Sync Locations from LibreNMS to Nautobot.", default=False) + location_type = ObjectVar( + model=LocationType, + queryset=LocationType.objects.all(), + display_field="name", + required=False, + label="Location Type", + description="Location Type to use for syncing locations to LibreNMS. This should be the Location Type that actually has devices assigned. For example, Site.", + ) tenant = ObjectVar( model=Tenant, queryset=Tenant.objects.all(), - description="Tenant to limit loading devices when syncing multiple LibreNMS Instances", + description="Tenant to filter loaded information from Nautobot when syncing multiple LibreNMS Instances", display_field="display", label="Tenant Filter", required=False, @@ -81,8 +90,8 @@ def config_information(cls): def data_mappings(cls): """List describing the data mappings involved in this DataSource.""" return ( - DataMapping("Location", "", "Location", "dcim.location"), - DataMapping("DeviceGroup", "", "Tag", "extras.tags"), + DataMapping("Geo Location", "", "Location", "dcim.location"), + DataMapping("Device Group", "", "Tag", "extras.tags"), DataMapping("Device", "", "Device", "dcim.device"), DataMapping("Port", "", "Interface", "dcim.interfaces"), DataMapping("IP", "", "IPAddress", "ipam.ip_address"), @@ -127,6 +136,7 @@ def run( librenms_server, hostname_field, sync_locations, + location_type, tenant, load_type, *args, @@ -137,6 +147,7 @@ def run( self.hostname_field = hostname_field self.load_type = load_type self.sync_locations = sync_locations + self.location_type = location_type self.tenant = tenant self.debug = debug self.dryrun = dryrun @@ -154,6 +165,27 @@ class LibrenmsDataTarget(DataTarget): required=True, label="LibreNMS Instance", ) + force_add = BooleanVar(description="Force add devices to LibreNMS (bypass ICMP check)", default=False) + ping_fallback = BooleanVar(description="Fallback to ICMP check if device is not reachable via SNMP", default=False) + sync_locations = BooleanVar(description="Whether to Sync Locations from Nautobot to LibreNMS.", default=False) + location_type = ObjectVar( + model=LocationType, + queryset=LocationType.objects.all(), + display_field="name", + required=False, + label="Location Type", + description="Location Type to use for syncing locations to LibreNMS. This should be the Location Type that actually has devices assigned. For example, Site.", + ) + hostname_field = "" + load_type = "" + tenant = ObjectVar( + model=Tenant, + queryset=Tenant.objects.all(), + description="Tenant to filter loaded information from Nautobot when syncing multiple LibreNMS Instances", + display_field="display", + label="Tenant Filter", + required=False, + ) debug = BooleanVar(description="Enable for more verbose debug logging", default=False) class Meta: # pylint: disable=too-few-public-methods @@ -164,6 +196,7 @@ class Meta: # pylint: disable=too-few-public-methods data_target = "LibreNMS" description = "Sync information from Nautobot to LibreNMS" data_target_icon = static("nautobot_ssot_librenms/librenms.svg") + has_sensitive_variables = False @classmethod def config_information(cls): @@ -173,7 +206,11 @@ def config_information(cls): @classmethod def data_mappings(cls): """List describing the data mappings involved in this DataSource.""" - return () + return ( + DataMapping("dcim.location", "", "Location", "Geo Location"), + DataMapping("extras.tags", "", "Tag", "Device Group"), + DataMapping("dcim.device", "", "Device", "Device"), + ) def load_source_adapter(self): """Load data from Nautobot into DiffSync models.""" @@ -202,14 +239,20 @@ def load_target_adapter(self): self.target_adapter = librenms.LibrenmsAdapter(job=self, sync=self.sync, librenms_api=librenms_api) self.target_adapter.load() - def run(self, dryrun, memory_profiling, debug, librenms_server, *args, **kwargs): # pylint: disable=arguments-differ + def run(self, dryrun, memory_profiling, debug, librenms_server, force_add, ping_fallback, sync_locations, location_type, *args, **kwargs): # pylint: disable=arguments-differ """Perform data synchronization.""" self.librenms_server = librenms_server + self.force_add = force_add + self.ping_fallback = ping_fallback + self.sync_locations = sync_locations + self.location_type = location_type + self.hostname_field == "env_var" + self.load_type == "api" self.debug = debug self.dryrun = dryrun self.memory_profiling = memory_profiling super().run(dryrun=self.dryrun, memory_profiling=self.memory_profiling, *args, **kwargs) -jobs = [LibrenmsDataSource] +jobs = [LibrenmsDataSource, LibrenmsDataTarget] register_jobs(*jobs) diff --git a/nautobot_ssot/integrations/librenms/utils/__init__.py b/nautobot_ssot/integrations/librenms/utils/__init__.py index 6a589fe7..bfbb4332 100644 --- a/nautobot_ssot/integrations/librenms/utils/__init__.py +++ b/nautobot_ssot/integrations/librenms/utils/__init__.py @@ -3,6 +3,7 @@ import inspect import logging import os +import ipaddress from constance import config as constance_name from django.conf import settings @@ -23,6 +24,14 @@ def normalize_setting(variable_name): return getattr(constance_name, f"{variable_name.upper()}") +def normalize_device_hostname(hostname): + """Normalize device hostname to be a valid LibreNMS or Nautobot hostname. Remove domain suffixes and uppercase the names for comparison (if not an IP Address)""" + if isinstance(hostname, ipaddress.IPv4Address) or isinstance(hostname, ipaddress.IPv6Address): + return hostname + hostname = hostname.split(".")[0] + return hostname.upper() + + def check_sor_field(model): """Check if the System of Record field is present and is set to "LibreNMS".""" return ( diff --git a/nautobot_ssot/integrations/librenms/utils/librenms.py b/nautobot_ssot/integrations/librenms/utils/librenms.py index f38b56c0..4921d27b 100644 --- a/nautobot_ssot/integrations/librenms/utils/librenms.py +++ b/nautobot_ssot/integrations/librenms/utils/librenms.py @@ -70,13 +70,15 @@ def api_call(self, path: str, method: str = "GET", params: dict = {}, payload: d else: params = {**self.params, **params} + LOGGER.debug(f"LibreNMS API Call: Headers: {self.headers} Method: {method} URL: {url} Params: {params} Payload: {payload}") + resp = requests.request( method=method, headers=self.headers, url=url, params=params, verify=self.verify, - data=payload, + json=payload, timeout=self.timeout, ) try: @@ -85,7 +87,7 @@ def api_call(self, path: str, method: str = "GET", params: dict = {}, payload: d return resp.json() except requests.exceptions.HTTPError as err: - LOGGER.log.error(f"Error in communicating to LibreNMS API: {err}") + LOGGER.error(f"Error in communicating to LibreNMS API: {err}") raise Exception(f"Error communicating to the LibreNMS API: {err}") @@ -185,13 +187,43 @@ def create_librenms_location(self, location: dict): url = "/api/v0/locations" method = "POST" data = location - response = self.api_call(path=url, method=method, data=data) + response = self.api_call(path=url, method=method, payload=data) return response def update_librenms_location(self, location: dict): """Update Location details to LibreNMS API endpoint.""" - url = "/api/v0/locations" + url = "/api/v0/locations{location}" method = "PATCH" data = location - response = self.api_call(path=url, method=method, data=data) + response = self.api_call(path=url, method=method, payload=data) + return response + + def delete_librenms_location(self, location: str): + """Delete Location details from LibreNMS API endpoint.""" + url = "/api/v0/locations/{location}" + method = "DELETE" + response = self.api_call(path=url, method=method) + return response + + def create_librenms_device(self, device: dict): + """Add Device details to LibreNMS API endpoint.""" + url = "/api/v0/devices" + method = "POST" + data = device + response = self.api_call(path=url, method=method, payload=data) + return response + + def update_librenms_device(self, device: dict): + """Update Device details to LibreNMS API endpoint.""" + url = "/api/v0/devices{device}" + method = "PATCH" + data = device + response = self.api_call(path=url, method=method, payload=data) + return response + + def delete_librenms_device(self, device: str): + """Delete Device details from LibreNMS API endpoint. Either hostname or device_id is required.""" + url = "/api/v0/devices/{device}" + method = "DELETE" + response = self.api_call(path=url, method=method) return response From 0d0717214c3097c5d3e7930e1ba6ef95f14386f3 Mon Sep 17 00:00:00 2001 From: "Zach Biles @bile0026" Date: Sun, 19 Jan 2025 11:36:19 -0600 Subject: [PATCH 02/22] =?UTF-8?q?test:=20=E2=9C=85=20add=20test=20fixtures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../librenms/fixtures/add_device_failure.json | 10 +++++++ .../fixtures/add_device_ping_fallback.json | 29 ++++++++++++++++++ .../librenms/fixtures/add_device_success.json | 30 +++++++++++++++++++ .../fixtures/add_location_failure.json | 3 ++ .../fixtures/add_location_success.json | 4 +++ 5 files changed, 76 insertions(+) create mode 100644 nautobot_ssot/tests/librenms/fixtures/add_device_failure.json create mode 100644 nautobot_ssot/tests/librenms/fixtures/add_device_ping_fallback.json create mode 100644 nautobot_ssot/tests/librenms/fixtures/add_device_success.json create mode 100644 nautobot_ssot/tests/librenms/fixtures/add_location_failure.json create mode 100644 nautobot_ssot/tests/librenms/fixtures/add_location_success.json diff --git a/nautobot_ssot/tests/librenms/fixtures/add_device_failure.json b/nautobot_ssot/tests/librenms/fixtures/add_device_failure.json new file mode 100644 index 00000000..503d76f3 --- /dev/null +++ b/nautobot_ssot/tests/librenms/fixtures/add_device_failure.json @@ -0,0 +1,10 @@ +{ + "no_ping": { + "status": "error", + "message": "Could not ping 192.168.1.3 (192.168.1.3)" + }, + "no_snmp": { + "status": "error", + "message": "Could not connect to 192.168.1.4, please check the snmp details and snmp reachability" + } +} diff --git a/nautobot_ssot/tests/librenms/fixtures/add_device_ping_fallback.json b/nautobot_ssot/tests/librenms/fixtures/add_device_ping_fallback.json new file mode 100644 index 00000000..d0671f0f --- /dev/null +++ b/nautobot_ssot/tests/librenms/fixtures/add_device_ping_fallback.json @@ -0,0 +1,29 @@ +{ + "status": "ok", + "devices": [ + { + "hostname": "192.168.1.4", + "display": "server", + "location_id": 2, + "port": 161, + "transport": "udp", + "poller_group": 0, + "os": "ping", + "status_reason": "", + "sysName": "192.168.1.4", + "port_association_mode": 1, + "snmpver": "v1", + "community": "public", + "authlevel": null, + "authname": null, + "authpass": "authpass", + "authalgo": null, + "cryptopass": null, + "cryptoalgo": null, + "snmp_disable": true, + "device_id": 6 + } + ], + "message": "Device 192.168.1.4 (6) has been added successfully", + "count": 1 +} \ No newline at end of file diff --git a/nautobot_ssot/tests/librenms/fixtures/add_device_success.json b/nautobot_ssot/tests/librenms/fixtures/add_device_success.json new file mode 100644 index 00000000..e49db994 --- /dev/null +++ b/nautobot_ssot/tests/librenms/fixtures/add_device_success.json @@ -0,0 +1,30 @@ +{ + "status": "ok", + "devices": [ + { + "hostname": "192.168.1.2", + "display": "librenmsdev", + "location_id": 2, + "port": 161, + "transport": "udp", + "poller_group": 0, + "os": "linux", + "status_reason": "", + "sysName": "librenmsdev", + "port_association_mode": 1, + "snmpver": "v3", + "community": null, + "authlevel": "authPriv", + "authname": "snmp_user", + "authpass": "authpass", + "authalgo": "SHA", + "cryptopass": "cryptopass", + "cryptoalgo": "AES", + "sysDescr": "Linux librenmsdev 5.15.0-130-generic #140-Ubuntu SMP Wed Dec 18 17:59:53 UTC 2024 x86_64", + "sysObjectID": ".1.3.6.1.4.1.8072.3.2.10", + "device_id": 5 + } + ], + "message": "Device 192.168.1.2 (5) has been added successfully", + "count": 1 +} \ No newline at end of file diff --git a/nautobot_ssot/tests/librenms/fixtures/add_location_failure.json b/nautobot_ssot/tests/librenms/fixtures/add_location_failure.json new file mode 100644 index 00000000..104f2047 --- /dev/null +++ b/nautobot_ssot/tests/librenms/fixtures/add_location_failure.json @@ -0,0 +1,3 @@ +{ + "message": "Server Error: Set APP_DEBUG=true to see details." +} diff --git a/nautobot_ssot/tests/librenms/fixtures/add_location_success.json b/nautobot_ssot/tests/librenms/fixtures/add_location_success.json new file mode 100644 index 00000000..b7f0ab00 --- /dev/null +++ b/nautobot_ssot/tests/librenms/fixtures/add_location_success.json @@ -0,0 +1,4 @@ +{ + "status": "ok", + "message": "Location added with id #2" +} From 12e910ff227f71d9f18c0e2af5a7878360d59cd7 Mon Sep 17 00:00:00 2001 From: "Zach Biles @bile0026" Date: Sun, 19 Jan 2025 11:41:49 -0600 Subject: [PATCH 03/22] =?UTF-8?q?docs:=20=F0=9F=93=9D=20add=20change=20fra?= =?UTF-8?q?gment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changes/672.added | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/672.added diff --git a/changes/672.added b/changes/672.added new file mode 100644 index 00000000..ad7c2381 --- /dev/null +++ b/changes/672.added @@ -0,0 +1 @@ +Added job LibreNMSDataTarget job to sync data from Nautobot to LibreNMS. \ No newline at end of file From 757f89b04ba1a21db52a7321f3ea17a78b585496 Mon Sep 17 00:00:00 2001 From: "Zach Biles @bile0026" Date: Sun, 19 Jan 2025 12:33:13 -0600 Subject: [PATCH 04/22] =?UTF-8?q?docs:=20=F0=9F=93=9D=20Add=20docs=20for?= =?UTF-8?q?=20LibreNMSDataTarget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/user/integrations/librenms.md | 34 ++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/docs/user/integrations/librenms.md b/docs/user/integrations/librenms.md index 97e7fa68..8513b568 100644 --- a/docs/user/integrations/librenms.md +++ b/docs/user/integrations/librenms.md @@ -11,25 +11,47 @@ The LibreNMS SSoT integration is built as part of the [Nautobot Single Source of - Debug: Additional Logging - Librenms Server: External integration object pointing to the required LibreNMS instance. - hostname_field: Which LibreNMS field to use as the hostname in Nautobot. sysName or hostanme. -- sync_location_parents: Whether to lookup City and State to add parent locations for geo locations. +- location_type: This is used to filter which locations are synced to LibreNMS. This should be the Location Type that actually has devices assigned. For example, Site. Since LibreNMS does not support nested locations. +- load_type: Whether to load data from a local fixture file or from the External Integration API. File is only used for testing. - tenant: This is used as a filter for objects synced with Nautobot and LibreNMS. This can be used to sync multiple LibreNMS instances into different tenants, like in an MSP environment. This affects which devices are loaded from Nautobot during the sync. It does not affect which devices are loaded from LibreNMS -From LibreNMS into Nautobot, the app synchronizes devices, their interfaces, associated IP addresses, and Locations. Here is a table showing the data mappings when syncing from LibreNMS. +From LibreNMS into Nautobot, the app synchronizes devices, and Locations. Here is a table showing the data mappings when syncing from LibreNMS to Nautobot. | LibreNMS objects | Nautobot objects | | ----------------------- | ---------------------------- | | geo location | Location | | device | Device | -| interface | Interface | +| interface | Interface `**` | | device os | Platform/Manufacturer `*` | | os version | Software/SoftwareImage | -| ip address | IPAddress | +| ip address | IPAddress `**` | | hardware | DeviceType | -`*` Device OS from LibreNMS is not standardized and therefore there is a mapping that can be updated in the `constants.py` file for the integration as more device manufacturers and platforms need to be added. +`*` Device OS from LibreNMS is not standardized and therefore there is a mapping that can be updated in the `constants.py` file for the integration as more device manufacturers and platforms need to be added. If new device manufacturers and platforms are added, open an issue or PR to add them. +`**` Not yet implemented, but planned for the future. ### LibreNMS as DataTarget -Not yet implemented. +This is a job that can be used to sync data from Nautobot to LibreNMS. +#### Job Options + +- Debug: Additional Logging +- Librenms Server: External integration object pointing to the required LibreNMS instance. +- hostname_field: Which LibreNMS field to use as the hostname in Nautobot. sysName or hostanme. +- sync_locations: Whether to sync locations from Nautobot to LibreNMS. +- location_type: This is used to filter which locations are synced to LibreNMS. This should be the Location Type that actually has devices assigned. For example, Site. Since LibreNMS does not support nested locations. +- tenant: This is used as a filter for objects synced with Nautobot and LibreNMS. This can be used to sync multiple LibreNMS instances into different tenants, like in an MSP environment. This affects which devices are loaded from Nautobot during the sync. It does not affect which devices are loaded from LibreNMS +- force_add: Whether to force add devices to LibreNMS. This will bypass the ICMP check. Will not work correctly until SNMP credential support is added to the LibreNMSDataTarget job. +- ping_fallback: Whether to add device as ping-only if device is not reachable via SNMP. + +From Nautobot into LibreNMS, the app synchronizes devices, and Locations. Here is a table showing the data mappings when syncing from Nautobot to LibreNMS. + +| Nautobot objects | LibreNMS objects | +| ---------------------------- | ----------------------- | +| Device | device `*` | +| Location | geo location `**` | + +`*` Devices in Nautobot must have a primary IP address set for them to be added to LibreNMS. +`**` Locations must have GPS coordinates set for them to be added to LibreNMS. From df57c5e9ffd0b1aa6739bdd3a85e563f83997553 Mon Sep 17 00:00:00 2001 From: "Zach Biles @bile0026" Date: Sun, 19 Jan 2025 12:33:32 -0600 Subject: [PATCH 05/22] =?UTF-8?q?fix:=20=F0=9F=90=9B=20add=20the=20custom?= =?UTF-8?q?=20field=20device=20ID=20if=20device=20successfully=20added=20t?= =?UTF-8?q?o=20LibreNMS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../librenms/diffsync/models/librenms.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/nautobot_ssot/integrations/librenms/diffsync/models/librenms.py b/nautobot_ssot/integrations/librenms/diffsync/models/librenms.py index 4f31ff72..8395ff9e 100644 --- a/nautobot_ssot/integrations/librenms/diffsync/models/librenms.py +++ b/nautobot_ssot/integrations/librenms/diffsync/models/librenms.py @@ -1,6 +1,8 @@ """Nautobot Ssot Librenms DiffSync models for Nautobot Ssot Librenms SSoT.""" from nautobot_ssot.integrations.librenms.diffsync.models.base import Device, Location +from django.contrib.contenttypes.models import ContentType +from nautobot.dcim.models import Device as NautobotDevice class LibrenmsLocation(Location): @@ -61,7 +63,15 @@ def create(cls, adapter, ids, attrs): if adapter.job.ping_fallback: device["ping_fallback"] = True adapter.job.logger.info(f"Creating device in LibreNMS: {device['hostname']}") - adapter.lnms_api.create_librenms_device(device) + response = adapter.lnms_api.create_librenms_device(device) + if response.get("status") == "error": + adapter.job.logger.error(f"Error creating device in LibreNMS: {response['message']}") + elif response.get("status") == "ok" and response.get("devices"): + # Get the device ID from the first device in the devices array + librenms_device_id = response["devices"][0]["device_id"] + nautobot_device = NautobotDevice.objects.get(name=ids["name"]) + nautobot_device.custom_field_data["librenms_device_id"] = librenms_device_id + nautobot_device.save() else: if adapter.job.debug: adapter.job.logger.debug(f"Skipping device in LibreNMS: {ids['name']}. No Primary IP address found.") From 8c2507b5666b97b22cd8a92a0de3f8c0e36362ab Mon Sep 17 00:00:00 2001 From: "Zach Biles @bile0026" Date: Sun, 19 Jan 2025 12:49:50 -0600 Subject: [PATCH 06/22] =?UTF-8?q?style:=20=F0=9F=94=A5=20remove=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nautobot_ssot/integrations/librenms/diffsync/models/librenms.py | 1 - 1 file changed, 1 deletion(-) diff --git a/nautobot_ssot/integrations/librenms/diffsync/models/librenms.py b/nautobot_ssot/integrations/librenms/diffsync/models/librenms.py index 8395ff9e..7fcefee9 100644 --- a/nautobot_ssot/integrations/librenms/diffsync/models/librenms.py +++ b/nautobot_ssot/integrations/librenms/diffsync/models/librenms.py @@ -50,7 +50,6 @@ class LibrenmsDevice(Device): def create(cls, adapter, ids, attrs): """Create Device in LibreNMS from LibrenmsDevice object.""" if attrs["status"] == "Active" or attrs["status"] == "Staged": - # Access the nested device dictionary correctly device_data = adapter.job.source_adapter.dict()["device"][ids["name"]] if device_data.get("ip_address"): device = { From eb7171e94691634cef2aa54de3572f76fff1d8ee Mon Sep 17 00:00:00 2001 From: "Zach Biles @bile0026" Date: Sun, 19 Jan 2025 12:56:57 -0600 Subject: [PATCH 07/22] =?UTF-8?q?style:=20=F0=9F=8E=A8=20fix=20ruff=20erro?= =?UTF-8?q?rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integrations/librenms/diffsync/adapters/librenms.py | 2 +- .../integrations/librenms/diffsync/adapters/nautobot.py | 6 +++++- .../integrations/librenms/diffsync/models/librenms.py | 4 ++-- nautobot_ssot/integrations/librenms/jobs.py | 2 +- nautobot_ssot/integrations/librenms/utils/__init__.py | 4 ++-- 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/nautobot_ssot/integrations/librenms/diffsync/adapters/librenms.py b/nautobot_ssot/integrations/librenms/diffsync/adapters/librenms.py index 55921a08..e2063e68 100644 --- a/nautobot_ssot/integrations/librenms/diffsync/adapters/librenms.py +++ b/nautobot_ssot/integrations/librenms/diffsync/adapters/librenms.py @@ -17,8 +17,8 @@ LibrenmsLocation, ) from nautobot_ssot.integrations.librenms.utils import ( - normalize_gps_coordinates, normalize_device_hostname, + normalize_gps_coordinates, ) from nautobot_ssot.integrations.librenms.utils.librenms import LibreNMSApi diff --git a/nautobot_ssot/integrations/librenms/diffsync/adapters/nautobot.py b/nautobot_ssot/integrations/librenms/diffsync/adapters/nautobot.py index 83047663..bd2fc490 100644 --- a/nautobot_ssot/integrations/librenms/diffsync/adapters/nautobot.py +++ b/nautobot_ssot/integrations/librenms/diffsync/adapters/nautobot.py @@ -13,7 +13,11 @@ NautobotDevice, NautobotLocation, ) -from nautobot_ssot.integrations.librenms.utils import check_sor_field, get_sor_field_nautobot_object, normalize_device_hostname +from nautobot_ssot.integrations.librenms.utils import ( + check_sor_field, + get_sor_field_nautobot_object, + normalize_device_hostname, +) class NautobotAdapter(DiffSync): diff --git a/nautobot_ssot/integrations/librenms/diffsync/models/librenms.py b/nautobot_ssot/integrations/librenms/diffsync/models/librenms.py index 7fcefee9..ed2d494d 100644 --- a/nautobot_ssot/integrations/librenms/diffsync/models/librenms.py +++ b/nautobot_ssot/integrations/librenms/diffsync/models/librenms.py @@ -1,9 +1,9 @@ """Nautobot Ssot Librenms DiffSync models for Nautobot Ssot Librenms SSoT.""" -from nautobot_ssot.integrations.librenms.diffsync.models.base import Device, Location -from django.contrib.contenttypes.models import ContentType from nautobot.dcim.models import Device as NautobotDevice +from nautobot_ssot.integrations.librenms.diffsync.models.base import Device, Location + class LibrenmsLocation(Location): """LibreNMS implementation of Location DiffSync model.""" diff --git a/nautobot_ssot/integrations/librenms/jobs.py b/nautobot_ssot/integrations/librenms/jobs.py index 219963f1..ed21c5f6 100644 --- a/nautobot_ssot/integrations/librenms/jobs.py +++ b/nautobot_ssot/integrations/librenms/jobs.py @@ -5,12 +5,12 @@ from django.templatetags.static import static from nautobot.apps.jobs import BooleanVar, ChoiceVar, ObjectVar from nautobot.core.celery import register_jobs +from nautobot.dcim.models import LocationType from nautobot.extras.choices import ( SecretsGroupAccessTypeChoices, SecretsGroupSecretTypeChoices, ) from nautobot.extras.models import ExternalIntegration -from nautobot.dcim.models import LocationType from nautobot.tenancy.models import Tenant from nautobot_ssot.integrations.librenms.diffsync.adapters import librenms, nautobot diff --git a/nautobot_ssot/integrations/librenms/utils/__init__.py b/nautobot_ssot/integrations/librenms/utils/__init__.py index bfbb4332..5e233cae 100644 --- a/nautobot_ssot/integrations/librenms/utils/__init__.py +++ b/nautobot_ssot/integrations/librenms/utils/__init__.py @@ -1,9 +1,9 @@ """Utility functions for working with LibreNMS and Nautobot.""" import inspect +import ipaddress import logging import os -import ipaddress from constance import config as constance_name from django.conf import settings @@ -25,7 +25,7 @@ def normalize_setting(variable_name): def normalize_device_hostname(hostname): - """Normalize device hostname to be a valid LibreNMS or Nautobot hostname. Remove domain suffixes and uppercase the names for comparison (if not an IP Address)""" + """Normalize device hostname to be a valid LibreNMS or Nautobot hostname. Remove domain suffixes and uppercase the names for comparison (if not an IP Address).""" if isinstance(hostname, ipaddress.IPv4Address) or isinstance(hostname, ipaddress.IPv6Address): return hostname hostname = hostname.split(".")[0] From 2bd3a4ae70d0f2f4571796fea1da12e0996cc885 Mon Sep 17 00:00:00 2001 From: "Zach Biles @bile0026" Date: Sun, 19 Jan 2025 13:21:37 -0600 Subject: [PATCH 08/22] =?UTF-8?q?style:=20=F0=9F=8E=A8=20fix=20ruff=20erro?= =?UTF-8?q?rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../librenms/diffsync/models/librenms.py | 12 +++++++++--- .../librenms/diffsync/models/nautobot.py | 4 +++- nautobot_ssot/integrations/librenms/jobs.py | 14 +++++++++++++- .../integrations/librenms/utils/librenms.py | 4 +++- .../fixtures/add_device_ping_fallback.json | 2 +- .../librenms/fixtures/add_device_success.json | 4 ++-- .../librenms/fixtures/get_librenms_devices.json | 4 ++-- 7 files changed, 33 insertions(+), 11 deletions(-) diff --git a/nautobot_ssot/integrations/librenms/diffsync/models/librenms.py b/nautobot_ssot/integrations/librenms/diffsync/models/librenms.py index ed2d494d..95d516fd 100644 --- a/nautobot_ssot/integrations/librenms/diffsync/models/librenms.py +++ b/nautobot_ssot/integrations/librenms/diffsync/models/librenms.py @@ -21,10 +21,14 @@ def create(cls, adapter, ids, attrs): adapter.job.logger.info(f"Creating location in LibreNMS: {location['location']}") adapter.lnms_api.create_librenms_location(location) else: - adapter.job.logger.warning(f"Skipping location in LibreNMS: {ids['name']}. Latitude or Longitude is not set, which LibreNMS requires.") + adapter.job.logger.warning( + f"Skipping location in LibreNMS: {ids['name']}. Latitude or Longitude is not set, which LibreNMS requires." + ) else: if adapter.job.debug: - adapter.job.logger.debug(f"Skipping location in LibreNMS: {ids['name']}. Status is not Active or Staged.") + adapter.job.logger.debug( + f"Skipping location in LibreNMS: {ids['name']}. Status is not Active or Staged." + ) return super().create(adapter=adapter, ids=ids, attrs=attrs) def update(self, attrs): @@ -73,7 +77,9 @@ def create(cls, adapter, ids, attrs): nautobot_device.save() else: if adapter.job.debug: - adapter.job.logger.debug(f"Skipping device in LibreNMS: {ids['name']}. No Primary IP address found.") + adapter.job.logger.debug( + f"Skipping device in LibreNMS: {ids['name']}. No Primary IP address found." + ) else: adapter.job.logger.info(f"Skipping device in LibreNMS: {ids['name']}. Status is not Active or Staged.") return super().create(adapter=adapter, ids=ids, attrs=attrs) diff --git a/nautobot_ssot/integrations/librenms/diffsync/models/nautobot.py b/nautobot_ssot/integrations/librenms/diffsync/models/nautobot.py index 7ad96661..cfdaf5db 100644 --- a/nautobot_ssot/integrations/librenms/diffsync/models/nautobot.py +++ b/nautobot_ssot/integrations/librenms/diffsync/models/nautobot.py @@ -70,7 +70,9 @@ def create(cls, adapter, ids, attrs): try: _location_type = LocationType.objects.get(id=adapter.job.location_type.id) except LocationType.DoesNotExist: - adapter.job.logger.warning(f"Location Type {adapter.job.location_type} does not exist. Using default Site Location Type.") + adapter.job.logger.warning( + f"Location Type {adapter.job.location_type} does not exist. Using default Site Location Type." + ) _location_type = LocationType.objects.get(name="Site") new_location = ORMLocation( diff --git a/nautobot_ssot/integrations/librenms/jobs.py b/nautobot_ssot/integrations/librenms/jobs.py index ed21c5f6..8b40704c 100644 --- a/nautobot_ssot/integrations/librenms/jobs.py +++ b/nautobot_ssot/integrations/librenms/jobs.py @@ -239,7 +239,19 @@ def load_target_adapter(self): self.target_adapter = librenms.LibrenmsAdapter(job=self, sync=self.sync, librenms_api=librenms_api) self.target_adapter.load() - def run(self, dryrun, memory_profiling, debug, librenms_server, force_add, ping_fallback, sync_locations, location_type, *args, **kwargs): # pylint: disable=arguments-differ + def run( + self, + dryrun, + memory_profiling, + debug, + librenms_server, + force_add, + ping_fallback, + sync_locations, + location_type, + *args, + **kwargs, + ): # pylint: disable=arguments-differ """Perform data synchronization.""" self.librenms_server = librenms_server self.force_add = force_add diff --git a/nautobot_ssot/integrations/librenms/utils/librenms.py b/nautobot_ssot/integrations/librenms/utils/librenms.py index 4921d27b..ee352397 100644 --- a/nautobot_ssot/integrations/librenms/utils/librenms.py +++ b/nautobot_ssot/integrations/librenms/utils/librenms.py @@ -70,7 +70,9 @@ def api_call(self, path: str, method: str = "GET", params: dict = {}, payload: d else: params = {**self.params, **params} - LOGGER.debug(f"LibreNMS API Call: Headers: {self.headers} Method: {method} URL: {url} Params: {params} Payload: {payload}") + LOGGER.debug( + f"LibreNMS API Call: Headers: {self.headers} Method: {method} URL: {url} Params: {params} Payload: {payload}" + ) resp = requests.request( method=method, diff --git a/nautobot_ssot/tests/librenms/fixtures/add_device_ping_fallback.json b/nautobot_ssot/tests/librenms/fixtures/add_device_ping_fallback.json index d0671f0f..57bba720 100644 --- a/nautobot_ssot/tests/librenms/fixtures/add_device_ping_fallback.json +++ b/nautobot_ssot/tests/librenms/fixtures/add_device_ping_fallback.json @@ -3,7 +3,7 @@ "devices": [ { "hostname": "192.168.1.4", - "display": "server", + "display": "SERVER", "location_id": 2, "port": 161, "transport": "udp", diff --git a/nautobot_ssot/tests/librenms/fixtures/add_device_success.json b/nautobot_ssot/tests/librenms/fixtures/add_device_success.json index e49db994..ecd62d7c 100644 --- a/nautobot_ssot/tests/librenms/fixtures/add_device_success.json +++ b/nautobot_ssot/tests/librenms/fixtures/add_device_success.json @@ -3,14 +3,14 @@ "devices": [ { "hostname": "192.168.1.2", - "display": "librenmsdev", + "display": "LIBRENMSDEV", "location_id": 2, "port": 161, "transport": "udp", "poller_group": 0, "os": "linux", "status_reason": "", - "sysName": "librenmsdev", + "sysName": "LIBRENMSDEV", "port_association_mode": 1, "snmpver": "v3", "community": null, diff --git a/nautobot_ssot/tests/librenms/fixtures/get_librenms_devices.json b/nautobot_ssot/tests/librenms/fixtures/get_librenms_devices.json index b254b9c8..56379c91 100644 --- a/nautobot_ssot/tests/librenms/fixtures/get_librenms_devices.json +++ b/nautobot_ssot/tests/librenms/fixtures/get_librenms_devices.json @@ -5,7 +5,7 @@ "device_id": 7, "inserted": "2023-04-21 23:01:34", "hostname": "10.0.10.11", - "sysName": "grch-ap-p2-utpo-303-60", + "sysName": "GRCH-AP-P2-UTPO-303-60", "display": null, "ip": "10.0.10.11", "overwrite_ip": null, @@ -62,7 +62,7 @@ "device_id": 1, "inserted": "2023-03-22 12:19:34", "hostname": "10.0.255.255", - "sysName": "grch-rt-core", + "sysName": "GRCH-RT-CORE", "display": null, "ip": "10.0.255.255", "overwrite_ip": "", From d187442df323b9c69ced26fd9c8271cc2d7f6483 Mon Sep 17 00:00:00 2001 From: "Zach Biles @bile0026" Date: Sun, 19 Jan 2025 18:19:27 -0600 Subject: [PATCH 09/22] =?UTF-8?q?test:=20=E2=9C=85=20add=20tests=20for=20l?= =?UTF-8?q?ibrenms=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/librenms/fixtures/__init__.py | 3 + .../tests/librenms/test_librenms_model.py | 330 ++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 nautobot_ssot/tests/librenms/test_librenms_model.py diff --git a/nautobot_ssot/tests/librenms/fixtures/__init__.py b/nautobot_ssot/tests/librenms/fixtures/__init__.py index 2012108a..4dd16c52 100644 --- a/nautobot_ssot/tests/librenms/fixtures/__init__.py +++ b/nautobot_ssot/tests/librenms/fixtures/__init__.py @@ -11,3 +11,6 @@ def load_json(path): DEVICE_FIXTURE_RECV = load_json("./nautobot_ssot/tests/librenms/fixtures/get_librenms_devices.json")["devices"] LOCATION_FIXURE_RECV = load_json("./nautobot_ssot/tests/librenms/fixtures/get_librenms_locations.json")["locations"] +ADD_LIBRENMS_DEVICE_SUCCESS = load_json("./nautobot_ssot/tests/librenms/fixtures/add_device_success.json") +ADD_LIBRENMS_DEVICE_FAILURE = load_json("./nautobot_ssot/tests/librenms/fixtures/add_device_failure.json") +ADD_LIBRENMS_DEVICE_PING_FALLBACK = load_json("./nautobot_ssot/tests/librenms/fixtures/add_device_ping_fallback.json") diff --git a/nautobot_ssot/tests/librenms/test_librenms_model.py b/nautobot_ssot/tests/librenms/test_librenms_model.py new file mode 100644 index 00000000..b078c278 --- /dev/null +++ b/nautobot_ssot/tests/librenms/test_librenms_model.py @@ -0,0 +1,330 @@ +"""Unit tests for LibreNMS DiffSync models.""" + +from unittest.mock import MagicMock, patch + +from diffsync import Adapter +from django.test import TestCase +from nautobot.dcim.models import Device as NautobotDevice +from nautobot.extras.models import Role, Status + +from nautobot_ssot.integrations.librenms.diffsync.models.librenms import LibrenmsDevice, LibrenmsLocation +from nautobot_ssot.tests.librenms.fixtures import ( + ADD_LIBRENMS_DEVICE_FAILURE, + ADD_LIBRENMS_DEVICE_PING_FALLBACK, + ADD_LIBRENMS_DEVICE_SUCCESS, +) + + +class TestAdapter(Adapter): + """Test adapter class that inherits from diffsync.Adapter.""" + def __init__(self): + super().__init__() + self.job = MagicMock() + self.lnms_api = MagicMock() + +class TestLibrenmsLocation(TestCase): + """Test cases for LibrenmsLocation model.""" + + def setUp(self): + """Set up test case.""" + mock_adapter = MagicMock(spec=Adapter) + mock_adapter.job = MagicMock() + mock_adapter.lnms_api = MagicMock() + self.adapter = mock_adapter + + Status.objects.get_or_create( + name="Active", + defaults={ + "color": "4caf50", + "description": "Unit Testing Active Status" + } + ) + + def test_create_location_success(self): + """Test creating a location with valid data.""" + ids = {"name": "City Hall"} + attrs = { + "status": "Active", + "latitude": 41.874677, + "longitude": -87.626728, + "location_type": "Site", + "system_of_record": "LibreNMS", + } + + LibrenmsLocation.create(self.adapter, ids, attrs) + + self.adapter.lnms_api.create_librenms_location.assert_called_once_with( + { + "location": "City Hall", + "lat": 41.874677, + "lng": -87.626728, + } + ) + + def test_create_location_no_coordinates(self): + """Test creating a location without coordinates.""" + ids = {"name": "City Hall"} + attrs = { + "status": "Active", + "latitude": None, + "longitude": None, + "location_type": "Site", + "system_of_record": "LibreNMS", + } + + LibrenmsLocation.create(self.adapter, ids, attrs) + + self.adapter.lnms_api.create_librenms_location.assert_not_called() + self.adapter.job.logger.warning.assert_called_once() + + def test_create_location_inactive(self): + """Test creating an inactive location.""" + ids = {"name": "City Hall"} + attrs = { + "status": "Offline", + "latitude": 41.874677, + "longitude": -87.626728, + "location_type": "Site", + "system_of_record": "LibreNMS", + } + + LibrenmsLocation.create(self.adapter, ids, attrs) + + self.adapter.lnms_api.create_librenms_location.assert_not_called() + + +class TestLibrenmsDevice(TestCase): + """Test cases for LibrenmsDevice model.""" + + def setUp(self): + """Set up test case.""" + super().setUp() + self.adapter = TestAdapter() + + self.success_response = ADD_LIBRENMS_DEVICE_SUCCESS + self.failure_response = ADD_LIBRENMS_DEVICE_FAILURE + self.ping_fallback_response = ADD_LIBRENMS_DEVICE_PING_FALLBACK + + self.status = Status.objects.get_or_create( + name="Active", + defaults={ + "color": "4caf50", + "description": "Unit Testing Active Status" + } + )[0] + + device_type = self.create_device_dependencies() + + from nautobot.dcim.models import Location, LocationType + location_type = LocationType.objects.get_or_create(name="Site")[0] + self.location = Location.objects.get_or_create( + name="Test Location", + location_type=location_type, + status=self.status, + defaults={ + "description": "Test Location for Unit Tests" + } + )[0] + + self.device_role = Role.objects.get_or_create( + name="Test Role", + defaults={ + "color": "ff0000" + } + )[0] + + self.device = NautobotDevice.objects.get_or_create( + name="test-device", + defaults={ + "status": self.status, + "device_type": device_type, + "location": self.location, + "role": self.device_role + } + )[0] + + def create_device_dependencies(self): + """Create the minimum required objects for a Device.""" + from nautobot.dcim.models import DeviceType, Manufacturer + + manufacturer = Manufacturer.objects.get_or_create( + name="Generic" + )[0] + device_type = DeviceType.objects.get_or_create( + manufacturer=manufacturer, + model="Test Device Type", + defaults={ + "manufacturer": manufacturer, + } + )[0] + return device_type + + @patch('nautobot.dcim.models.Device.objects.get') + def test_create_device_success(self, mock_device_get): + """Test creating a device with valid data.""" + device_name = "test-device" + ids = {"name": device_name} + attrs = { + "ip_address": "192.168.1.1", + "location": "City Hall", + "device_type": "Generic Device", + "manufacturer": "Generic", + "system_of_record": "LibreNMS", + "status": "Active", + } + + self.adapter.job.source_adapter.dict.return_value = {"device": {device_name: attrs}} + + LibrenmsDevice.create(self.adapter, ids, attrs) + + self.adapter.lnms_api.create_librenms_device.assert_called_once_with({ + 'hostname': '192.168.1.1', + 'display': device_name, + 'location': 'City Hall', + 'force_add': True, + 'ping_fallback': True + }) + + def test_create_device_default(self): + """Test creating a device with default settings.""" + device_name = "LIBRENMSDEV" + ids = {"name": device_name} + attrs = { + "ip_address": "192.168.1.2", + "location": "City Hall", + "device_type": "Generic Device", + "manufacturer": "Generic", + "system_of_record": "LibreNMS", + "status": "Active", + } + + device_type = self.create_device_dependencies() + NautobotDevice.objects.get_or_create( + name=device_name, + defaults={ + "status": self.status, + "device_type": device_type, + "location": self.location, + "role": self.device_role + } + ) + + self.adapter.job.source_adapter.dict.return_value = {"device": {device_name: attrs}} + + self.adapter.lnms_api.create_librenms_device.return_value = self.success_response + + device = LibrenmsDevice.create(self.adapter, ids, attrs) + + self.adapter.lnms_api.create_librenms_device.assert_called_once_with({ + 'hostname': '192.168.1.2', + 'display': device_name, + 'location': 'City Hall', + 'force_add': True, + 'ping_fallback': True + }) + + self.assertEqual(device.name, self.success_response["devices"][0]["display"]) + + def test_create_device_with_ping_fallback(self): + """Test creating a device with ping_fallback enabled.""" + ids = {"name": "test-device"} + attrs = { + "ip_address": "192.168.1.1", + "location": "City Hall", + "device_type": "Generic Device", + "manufacturer": "Generic", + "system_of_record": "LibreNMS", + "platform": "linux", + "role": "Server", + "status": "Active", + "os_version": "1.0", + "device_id": "123", + "serial_no": "ABC123" + } + + mock_adapter = MagicMock(spec=Adapter) + mock_adapter.job = MagicMock() + mock_adapter.lnms_api = MagicMock() + mock_adapter.dict.return_value = {"device": {"test-device": attrs}} + + LibrenmsDevice.create(mock_adapter, ids, attrs) + + def test_create_device_failure_no_ping(self): + """Test creating a device with ping failure.""" + device_name = "test-device-no-ping" + ids = {"name": device_name} + attrs = { + "ip_address": "192.168.1.3", + "location": "City Hall", + "device_type": "Generic Device", + "manufacturer": "Generic", + "system_of_record": "LibreNMS", + "status": "Active" + } + + self.adapter.job.source_adapter.dict.return_value = {"device": {device_name: attrs}} + + self.adapter.lnms_api.create_librenms_device.side_effect = Exception( + self.failure_response["no_ping"]["message"] + ) + + with self.assertRaises(Exception) as context: + LibrenmsDevice.create(self.adapter, ids, attrs) + + self.assertEqual(str(context.exception), self.failure_response["no_ping"]["message"]) + + def test_create_device_failure_no_snmp(self): + """Test creating a device with SNMP failure.""" + device_name = "test-device-no-snmp" + ids = {"name": device_name} + attrs = { + "ip_address": "192.168.1.4", + "location": "City Hall", + "device_type": "Generic Device", + "manufacturer": "Generic", + "system_of_record": "LibreNMS", + "status": "Active" + } + + self.adapter.job.source_adapter.dict.return_value = {"device": {device_name: attrs}} + + self.adapter.lnms_api.create_librenms_device.side_effect = Exception( + self.failure_response["no_snmp"]["message"] + ) + + with self.assertRaises(Exception) as context: + LibrenmsDevice.create(self.adapter, ids, attrs) + + self.assertEqual(str(context.exception), self.failure_response["no_snmp"]["message"]) + + def test_create_device_no_ip(self): + """Test creating a device without an IP address.""" + ids = {"name": "test-device"} + attrs = { + "status": "Active", + "location": "City Hall", + "device_type": "Linux", + "manufacturer": "Generic", + "system_of_record": "LibreNMS", + } + self.adapter.job.source_adapter.dict.return_value = {"device": {"test-device": {"ip_address": None}}} + + LibrenmsDevice.create(self.adapter, ids, attrs) + + self.adapter.lnms_api.create_librenms_device.assert_not_called() + self.adapter.job.logger.debug.assert_called_once() + + def test_create_device_inactive(self): + """Test creating an inactive device.""" + ids = {"name": "test-device"} + attrs = { + "status": "Offline", + "location": "City Hall", + "device_type": "Linux", + "manufacturer": "Generic", + "system_of_record": "LibreNMS", + } + + LibrenmsDevice.create(self.adapter, ids, attrs) + + self.adapter.lnms_api.create_librenms_device.assert_not_called() From 6c25a39a1aa66866b7a3080f53fdf57023e0e6c5 Mon Sep 17 00:00:00 2001 From: "Zach Biles @bile0026" Date: Sun, 19 Jan 2025 18:23:33 -0600 Subject: [PATCH 10/22] =?UTF-8?q?style:=20=F0=9F=8E=A8=20fix=20ruff=20erro?= =?UTF-8?q?rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/librenms/test_librenms_model.py | 80 ++++++++----------- 1 file changed, 35 insertions(+), 45 deletions(-) diff --git a/nautobot_ssot/tests/librenms/test_librenms_model.py b/nautobot_ssot/tests/librenms/test_librenms_model.py index b078c278..a543356c 100644 --- a/nautobot_ssot/tests/librenms/test_librenms_model.py +++ b/nautobot_ssot/tests/librenms/test_librenms_model.py @@ -17,11 +17,13 @@ class TestAdapter(Adapter): """Test adapter class that inherits from diffsync.Adapter.""" + def __init__(self): super().__init__() self.job = MagicMock() self.lnms_api = MagicMock() + class TestLibrenmsLocation(TestCase): """Test cases for LibrenmsLocation model.""" @@ -33,11 +35,7 @@ def setUp(self): self.adapter = mock_adapter Status.objects.get_or_create( - name="Active", - defaults={ - "color": "4caf50", - "description": "Unit Testing Active Status" - } + name="Active", defaults={"color": "4caf50", "description": "Unit Testing Active Status"} ) def test_create_location_success(self): @@ -106,32 +104,22 @@ def setUp(self): self.ping_fallback_response = ADD_LIBRENMS_DEVICE_PING_FALLBACK self.status = Status.objects.get_or_create( - name="Active", - defaults={ - "color": "4caf50", - "description": "Unit Testing Active Status" - } + name="Active", defaults={"color": "4caf50", "description": "Unit Testing Active Status"} )[0] device_type = self.create_device_dependencies() from nautobot.dcim.models import Location, LocationType + location_type = LocationType.objects.get_or_create(name="Site")[0] self.location = Location.objects.get_or_create( name="Test Location", location_type=location_type, status=self.status, - defaults={ - "description": "Test Location for Unit Tests" - } + defaults={"description": "Test Location for Unit Tests"}, )[0] - self.device_role = Role.objects.get_or_create( - name="Test Role", - defaults={ - "color": "ff0000" - } - )[0] + self.device_role = Role.objects.get_or_create(name="Test Role", defaults={"color": "ff0000"})[0] self.device = NautobotDevice.objects.get_or_create( name="test-device", @@ -139,27 +127,25 @@ def setUp(self): "status": self.status, "device_type": device_type, "location": self.location, - "role": self.device_role - } + "role": self.device_role, + }, )[0] def create_device_dependencies(self): """Create the minimum required objects for a Device.""" from nautobot.dcim.models import DeviceType, Manufacturer - manufacturer = Manufacturer.objects.get_or_create( - name="Generic" - )[0] + manufacturer = Manufacturer.objects.get_or_create(name="Generic")[0] device_type = DeviceType.objects.get_or_create( manufacturer=manufacturer, model="Test Device Type", defaults={ "manufacturer": manufacturer, - } + }, )[0] return device_type - @patch('nautobot.dcim.models.Device.objects.get') + @patch("nautobot.dcim.models.Device.objects.get") def test_create_device_success(self, mock_device_get): """Test creating a device with valid data.""" device_name = "test-device" @@ -177,13 +163,15 @@ def test_create_device_success(self, mock_device_get): LibrenmsDevice.create(self.adapter, ids, attrs) - self.adapter.lnms_api.create_librenms_device.assert_called_once_with({ - 'hostname': '192.168.1.1', - 'display': device_name, - 'location': 'City Hall', - 'force_add': True, - 'ping_fallback': True - }) + self.adapter.lnms_api.create_librenms_device.assert_called_once_with( + { + "hostname": "192.168.1.1", + "display": device_name, + "location": "City Hall", + "force_add": True, + "ping_fallback": True, + } + ) def test_create_device_default(self): """Test creating a device with default settings.""" @@ -205,8 +193,8 @@ def test_create_device_default(self): "status": self.status, "device_type": device_type, "location": self.location, - "role": self.device_role - } + "role": self.device_role, + }, ) self.adapter.job.source_adapter.dict.return_value = {"device": {device_name: attrs}} @@ -215,13 +203,15 @@ def test_create_device_default(self): device = LibrenmsDevice.create(self.adapter, ids, attrs) - self.adapter.lnms_api.create_librenms_device.assert_called_once_with({ - 'hostname': '192.168.1.2', - 'display': device_name, - 'location': 'City Hall', - 'force_add': True, - 'ping_fallback': True - }) + self.adapter.lnms_api.create_librenms_device.assert_called_once_with( + { + "hostname": "192.168.1.2", + "display": device_name, + "location": "City Hall", + "force_add": True, + "ping_fallback": True, + } + ) self.assertEqual(device.name, self.success_response["devices"][0]["display"]) @@ -239,7 +229,7 @@ def test_create_device_with_ping_fallback(self): "status": "Active", "os_version": "1.0", "device_id": "123", - "serial_no": "ABC123" + "serial_no": "ABC123", } mock_adapter = MagicMock(spec=Adapter) @@ -259,7 +249,7 @@ def test_create_device_failure_no_ping(self): "device_type": "Generic Device", "manufacturer": "Generic", "system_of_record": "LibreNMS", - "status": "Active" + "status": "Active", } self.adapter.job.source_adapter.dict.return_value = {"device": {device_name: attrs}} @@ -283,7 +273,7 @@ def test_create_device_failure_no_snmp(self): "device_type": "Generic Device", "manufacturer": "Generic", "system_of_record": "LibreNMS", - "status": "Active" + "status": "Active", } self.adapter.job.source_adapter.dict.return_value = {"device": {device_name: attrs}} From ebbdb997aa18c20788aae58dcc91bc81f01c2bf9 Mon Sep 17 00:00:00 2001 From: "Zach Biles @bile0026" Date: Sun, 19 Jan 2025 18:41:42 -0600 Subject: [PATCH 11/22] =?UTF-8?q?fix:=20=F0=9F=90=9B=20fix=20pylint=20erro?= =?UTF-8?q?rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/librenms/test_librenms_model.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/nautobot_ssot/tests/librenms/test_librenms_model.py b/nautobot_ssot/tests/librenms/test_librenms_model.py index a543356c..cdab31cf 100644 --- a/nautobot_ssot/tests/librenms/test_librenms_model.py +++ b/nautobot_ssot/tests/librenms/test_librenms_model.py @@ -5,6 +5,7 @@ from diffsync import Adapter from django.test import TestCase from nautobot.dcim.models import Device as NautobotDevice +from nautobot.dcim.models import DeviceType, Location, LocationType, Manufacturer from nautobot.extras.models import Role, Status from nautobot_ssot.integrations.librenms.diffsync.models.librenms import LibrenmsDevice, LibrenmsLocation @@ -98,10 +99,12 @@ def setUp(self): """Set up test case.""" super().setUp() self.adapter = TestAdapter() - - self.success_response = ADD_LIBRENMS_DEVICE_SUCCESS - self.failure_response = ADD_LIBRENMS_DEVICE_FAILURE - self.ping_fallback_response = ADD_LIBRENMS_DEVICE_PING_FALLBACK + + self.responses = { + 'success': ADD_LIBRENMS_DEVICE_SUCCESS, + 'failure': ADD_LIBRENMS_DEVICE_FAILURE, + 'ping_fallback': ADD_LIBRENMS_DEVICE_PING_FALLBACK + } self.status = Status.objects.get_or_create( name="Active", defaults={"color": "4caf50", "description": "Unit Testing Active Status"} @@ -109,8 +112,6 @@ def setUp(self): device_type = self.create_device_dependencies() - from nautobot.dcim.models import Location, LocationType - location_type = LocationType.objects.get_or_create(name="Site")[0] self.location = Location.objects.get_or_create( name="Test Location", @@ -133,7 +134,6 @@ def setUp(self): def create_device_dependencies(self): """Create the minimum required objects for a Device.""" - from nautobot.dcim.models import DeviceType, Manufacturer manufacturer = Manufacturer.objects.get_or_create(name="Generic")[0] device_type = DeviceType.objects.get_or_create( @@ -146,7 +146,7 @@ def create_device_dependencies(self): return device_type @patch("nautobot.dcim.models.Device.objects.get") - def test_create_device_success(self, mock_device_get): + def test_create_device_success(self): """Test creating a device with valid data.""" device_name = "test-device" ids = {"name": device_name} @@ -199,7 +199,7 @@ def test_create_device_default(self): self.adapter.job.source_adapter.dict.return_value = {"device": {device_name: attrs}} - self.adapter.lnms_api.create_librenms_device.return_value = self.success_response + self.adapter.lnms_api.create_librenms_device.return_value = self.responses['success'] device = LibrenmsDevice.create(self.adapter, ids, attrs) @@ -213,7 +213,7 @@ def test_create_device_default(self): } ) - self.assertEqual(device.name, self.success_response["devices"][0]["display"]) + self.assertEqual(device.name, self.responses['success']["devices"][0]["display"]) def test_create_device_with_ping_fallback(self): """Test creating a device with ping_fallback enabled.""" @@ -261,7 +261,7 @@ def test_create_device_failure_no_ping(self): with self.assertRaises(Exception) as context: LibrenmsDevice.create(self.adapter, ids, attrs) - self.assertEqual(str(context.exception), self.failure_response["no_ping"]["message"]) + self.assertEqual(str(context.exception), self.responses['failure']["no_ping"]["message"]) def test_create_device_failure_no_snmp(self): """Test creating a device with SNMP failure.""" @@ -279,13 +279,13 @@ def test_create_device_failure_no_snmp(self): self.adapter.job.source_adapter.dict.return_value = {"device": {device_name: attrs}} self.adapter.lnms_api.create_librenms_device.side_effect = Exception( - self.failure_response["no_snmp"]["message"] + self.responses['failure']["no_snmp"]["message"] ) with self.assertRaises(Exception) as context: LibrenmsDevice.create(self.adapter, ids, attrs) - self.assertEqual(str(context.exception), self.failure_response["no_snmp"]["message"]) + self.assertEqual(str(context.exception), self.responses['failure']["no_snmp"]["message"]) def test_create_device_no_ip(self): """Test creating a device without an IP address.""" From 2669b449315f9999c327e8dace8476338c361071 Mon Sep 17 00:00:00 2001 From: "Zach Biles @bile0026" Date: Sun, 19 Jan 2025 18:43:50 -0600 Subject: [PATCH 12/22] =?UTF-8?q?style:=20=F0=9F=8E=A8=20fix=20ruff=20erro?= =?UTF-8?q?rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/librenms/test_librenms_model.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/nautobot_ssot/tests/librenms/test_librenms_model.py b/nautobot_ssot/tests/librenms/test_librenms_model.py index cdab31cf..8b5219bf 100644 --- a/nautobot_ssot/tests/librenms/test_librenms_model.py +++ b/nautobot_ssot/tests/librenms/test_librenms_model.py @@ -99,11 +99,11 @@ def setUp(self): """Set up test case.""" super().setUp() self.adapter = TestAdapter() - + self.responses = { - 'success': ADD_LIBRENMS_DEVICE_SUCCESS, - 'failure': ADD_LIBRENMS_DEVICE_FAILURE, - 'ping_fallback': ADD_LIBRENMS_DEVICE_PING_FALLBACK + "success": ADD_LIBRENMS_DEVICE_SUCCESS, + "failure": ADD_LIBRENMS_DEVICE_FAILURE, + "ping_fallback": ADD_LIBRENMS_DEVICE_PING_FALLBACK, } self.status = Status.objects.get_or_create( @@ -199,7 +199,7 @@ def test_create_device_default(self): self.adapter.job.source_adapter.dict.return_value = {"device": {device_name: attrs}} - self.adapter.lnms_api.create_librenms_device.return_value = self.responses['success'] + self.adapter.lnms_api.create_librenms_device.return_value = self.responses["success"] device = LibrenmsDevice.create(self.adapter, ids, attrs) @@ -213,7 +213,7 @@ def test_create_device_default(self): } ) - self.assertEqual(device.name, self.responses['success']["devices"][0]["display"]) + self.assertEqual(device.name, self.responses["success"]["devices"][0]["display"]) def test_create_device_with_ping_fallback(self): """Test creating a device with ping_fallback enabled.""" @@ -261,7 +261,7 @@ def test_create_device_failure_no_ping(self): with self.assertRaises(Exception) as context: LibrenmsDevice.create(self.adapter, ids, attrs) - self.assertEqual(str(context.exception), self.responses['failure']["no_ping"]["message"]) + self.assertEqual(str(context.exception), self.responses["failure"]["no_ping"]["message"]) def test_create_device_failure_no_snmp(self): """Test creating a device with SNMP failure.""" @@ -279,13 +279,13 @@ def test_create_device_failure_no_snmp(self): self.adapter.job.source_adapter.dict.return_value = {"device": {device_name: attrs}} self.adapter.lnms_api.create_librenms_device.side_effect = Exception( - self.responses['failure']["no_snmp"]["message"] + self.responses["failure"]["no_snmp"]["message"] ) with self.assertRaises(Exception) as context: LibrenmsDevice.create(self.adapter, ids, attrs) - self.assertEqual(str(context.exception), self.responses['failure']["no_snmp"]["message"]) + self.assertEqual(str(context.exception), self.responses["failure"]["no_snmp"]["message"]) def test_create_device_no_ip(self): """Test creating a device without an IP address.""" From c11d48aa0861a1a80cf455c689bd1403cfe7bf11 Mon Sep 17 00:00:00 2001 From: "Zach Biles @bile0026" Date: Sun, 19 Jan 2025 18:56:54 -0600 Subject: [PATCH 13/22] =?UTF-8?q?test:=20=F0=9F=8E=A8=20fix=20pylint=20err?= =?UTF-8?q?ors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nautobot_ssot/tests/librenms/test_librenms_model.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nautobot_ssot/tests/librenms/test_librenms_model.py b/nautobot_ssot/tests/librenms/test_librenms_model.py index 8b5219bf..905b0602 100644 --- a/nautobot_ssot/tests/librenms/test_librenms_model.py +++ b/nautobot_ssot/tests/librenms/test_librenms_model.py @@ -249,19 +249,19 @@ def test_create_device_failure_no_ping(self): "device_type": "Generic Device", "manufacturer": "Generic", "system_of_record": "LibreNMS", - "status": "Active", + "status": "Active" } self.adapter.job.source_adapter.dict.return_value = {"device": {device_name: attrs}} self.adapter.lnms_api.create_librenms_device.side_effect = Exception( - self.failure_response["no_ping"]["message"] + self.responses['failure']['no_ping']['message'] ) with self.assertRaises(Exception) as context: LibrenmsDevice.create(self.adapter, ids, attrs) - self.assertEqual(str(context.exception), self.responses["failure"]["no_ping"]["message"]) + self.assertEqual(str(context.exception), self.responses['failure']['no_ping']['message']) def test_create_device_failure_no_snmp(self): """Test creating a device with SNMP failure.""" From 32e1184259a7c7a178f60334db0769a893417871 Mon Sep 17 00:00:00 2001 From: "Zach Biles @bile0026" Date: Sun, 19 Jan 2025 19:08:10 -0600 Subject: [PATCH 14/22] =?UTF-8?q?style:=20=F0=9F=8E=A8=20fix=20ruff=20styl?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nautobot_ssot/tests/librenms/test_librenms_model.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nautobot_ssot/tests/librenms/test_librenms_model.py b/nautobot_ssot/tests/librenms/test_librenms_model.py index 905b0602..133c534d 100644 --- a/nautobot_ssot/tests/librenms/test_librenms_model.py +++ b/nautobot_ssot/tests/librenms/test_librenms_model.py @@ -249,19 +249,19 @@ def test_create_device_failure_no_ping(self): "device_type": "Generic Device", "manufacturer": "Generic", "system_of_record": "LibreNMS", - "status": "Active" + "status": "Active", } self.adapter.job.source_adapter.dict.return_value = {"device": {device_name: attrs}} self.adapter.lnms_api.create_librenms_device.side_effect = Exception( - self.responses['failure']['no_ping']['message'] + self.responses["failure"]["no_ping"]["message"] ) with self.assertRaises(Exception) as context: LibrenmsDevice.create(self.adapter, ids, attrs) - self.assertEqual(str(context.exception), self.responses['failure']['no_ping']['message']) + self.assertEqual(str(context.exception), self.responses["failure"]["no_ping"]["message"]) def test_create_device_failure_no_snmp(self): """Test creating a device with SNMP failure.""" From d03226ea3babb3bfbd06b4aa8394b9cad5887ba0 Mon Sep 17 00:00:00 2001 From: "Zach Biles @bile0026" Date: Tue, 21 Jan 2025 06:57:11 -0600 Subject: [PATCH 15/22] =?UTF-8?q?fix:=20=E2=9C=85=20fix=20unit=20test=20er?= =?UTF-8?q?ror?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nautobot_ssot/tests/librenms/test_librenms_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nautobot_ssot/tests/librenms/test_librenms_model.py b/nautobot_ssot/tests/librenms/test_librenms_model.py index 133c534d..c183da69 100644 --- a/nautobot_ssot/tests/librenms/test_librenms_model.py +++ b/nautobot_ssot/tests/librenms/test_librenms_model.py @@ -146,7 +146,7 @@ def create_device_dependencies(self): return device_type @patch("nautobot.dcim.models.Device.objects.get") - def test_create_device_success(self): + def test_create_device_success(self, mock_device_get): # pylint: disable=W0613 """Test creating a device with valid data.""" device_name = "test-device" ids = {"name": device_name} From cc1c354b7a83e073069f8ff9343fcfc52410595f Mon Sep 17 00:00:00 2001 From: Zach Biles Date: Tue, 21 Jan 2025 13:16:32 -0600 Subject: [PATCH 16/22] Update docs/user/integrations/librenms.md Co-authored-by: Justin Drew <2396364+jdrew82@users.noreply.github.com> --- docs/user/integrations/librenms.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/integrations/librenms.md b/docs/user/integrations/librenms.md index 8513b568..8a66eb98 100644 --- a/docs/user/integrations/librenms.md +++ b/docs/user/integrations/librenms.md @@ -11,7 +11,7 @@ The LibreNMS SSoT integration is built as part of the [Nautobot Single Source of - Debug: Additional Logging - Librenms Server: External integration object pointing to the required LibreNMS instance. - hostname_field: Which LibreNMS field to use as the hostname in Nautobot. sysName or hostanme. -- location_type: This is used to filter which locations are synced to LibreNMS. This should be the Location Type that actually has devices assigned. For example, Site. Since LibreNMS does not support nested locations. +- location_type: This is used to filter which locations are synced to LibreNMS. This should be the Location Type, such as Site, that actually has devices assigned since LibreNMS does not support nested locations. - load_type: Whether to load data from a local fixture file or from the External Integration API. File is only used for testing. - tenant: This is used as a filter for objects synced with Nautobot and LibreNMS. This can be used to sync multiple LibreNMS instances into different tenants, like in an MSP environment. This affects which devices are loaded from Nautobot during the sync. It does not affect which devices are loaded from LibreNMS From 7ee1eda98de1e6774d2cb771123f91772e0aa70d Mon Sep 17 00:00:00 2001 From: Zach Biles Date: Tue, 21 Jan 2025 13:18:24 -0600 Subject: [PATCH 17/22] Update docs/user/integrations/librenms.md Co-authored-by: Justin Drew <2396364+jdrew82@users.noreply.github.com> --- docs/user/integrations/librenms.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/integrations/librenms.md b/docs/user/integrations/librenms.md index 8a66eb98..68736a64 100644 --- a/docs/user/integrations/librenms.md +++ b/docs/user/integrations/librenms.md @@ -38,7 +38,7 @@ This is a job that can be used to sync data from Nautobot to LibreNMS. #### Job Options - Debug: Additional Logging -- Librenms Server: External integration object pointing to the required LibreNMS instance. +- LibreNMS Server: External integration object pointing to the required LibreNMS instance. - hostname_field: Which LibreNMS field to use as the hostname in Nautobot. sysName or hostanme. - sync_locations: Whether to sync locations from Nautobot to LibreNMS. - location_type: This is used to filter which locations are synced to LibreNMS. This should be the Location Type that actually has devices assigned. For example, Site. Since LibreNMS does not support nested locations. From 783eb9467b867a23a139d88955975606239e2229 Mon Sep 17 00:00:00 2001 From: Zach Biles Date: Tue, 21 Jan 2025 13:18:34 -0600 Subject: [PATCH 18/22] Update docs/user/integrations/librenms.md Co-authored-by: Justin Drew <2396364+jdrew82@users.noreply.github.com> --- docs/user/integrations/librenms.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/integrations/librenms.md b/docs/user/integrations/librenms.md index 68736a64..ae732593 100644 --- a/docs/user/integrations/librenms.md +++ b/docs/user/integrations/librenms.md @@ -39,7 +39,7 @@ This is a job that can be used to sync data from Nautobot to LibreNMS. - Debug: Additional Logging - LibreNMS Server: External integration object pointing to the required LibreNMS instance. -- hostname_field: Which LibreNMS field to use as the hostname in Nautobot. sysName or hostanme. +- hostname_field: Which LibreNMS field to use as the hostname in Nautobot, sysName or hostname. - sync_locations: Whether to sync locations from Nautobot to LibreNMS. - location_type: This is used to filter which locations are synced to LibreNMS. This should be the Location Type that actually has devices assigned. For example, Site. Since LibreNMS does not support nested locations. - tenant: This is used as a filter for objects synced with Nautobot and LibreNMS. This can be used to sync multiple LibreNMS instances into different tenants, like in an MSP environment. This affects which devices are loaded from Nautobot during the sync. It does not affect which devices are loaded from LibreNMS From 6217ff0e7f8211f82f87b87c781d822b53159e5b Mon Sep 17 00:00:00 2001 From: "Zach Biles @bile0026" Date: Tue, 21 Jan 2025 13:28:20 -0600 Subject: [PATCH 19/22] =?UTF-8?q?docs:=20=F0=9F=93=9D=20update=20documenta?= =?UTF-8?q?tion=20for=20clarity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/user/integrations/librenms.md | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/docs/user/integrations/librenms.md b/docs/user/integrations/librenms.md index 8513b568..e4c7bb3f 100644 --- a/docs/user/integrations/librenms.md +++ b/docs/user/integrations/librenms.md @@ -2,19 +2,24 @@ ## Process -### LibreNMS as DataSource - -The LibreNMS SSoT integration is built as part of the [Nautobot Single Source of Truth (SSoT)](https://github.com/nautobot/nautobot-app-ssot) app. the SSoT app enables Nautobot to be the aggregation point for data coming from multiple systems of record (SoR). - -#### Job Options +### Shared Job Options - Debug: Additional Logging - Librenms Server: External integration object pointing to the required LibreNMS instance. - hostname_field: Which LibreNMS field to use as the hostname in Nautobot. sysName or hostanme. +- sync_locations: Whether to sync locations from Nautobot to LibreNMS. - location_type: This is used to filter which locations are synced to LibreNMS. This should be the Location Type that actually has devices assigned. For example, Site. Since LibreNMS does not support nested locations. -- load_type: Whether to load data from a local fixture file or from the External Integration API. File is only used for testing. - tenant: This is used as a filter for objects synced with Nautobot and LibreNMS. This can be used to sync multiple LibreNMS instances into different tenants, like in an MSP environment. This affects which devices are loaded from Nautobot during the sync. It does not affect which devices are loaded from LibreNMS + +### LibreNMS as DataSource + +The LibreNMS SSoT integration is built as part of the [Nautobot Single Source of Truth (SSoT)](https://github.com/nautobot/nautobot-app-ssot) app. the SSoT app enables Nautobot to be the aggregation point for data coming from multiple systems of record (SoR). + +#### Job Specific Options + +- load_type: Whether to load data from a local fixture file or from the External Integration API. File is only used for testing or trying out the integration without a connection to a LibreNMS instance. + From LibreNMS into Nautobot, the app synchronizes devices, and Locations. Here is a table showing the data mappings when syncing from LibreNMS to Nautobot. | LibreNMS objects | Nautobot objects | @@ -35,14 +40,8 @@ From LibreNMS into Nautobot, the app synchronizes devices, and Locations. Here i This is a job that can be used to sync data from Nautobot to LibreNMS. -#### Job Options +#### Job Specific Options -- Debug: Additional Logging -- Librenms Server: External integration object pointing to the required LibreNMS instance. -- hostname_field: Which LibreNMS field to use as the hostname in Nautobot. sysName or hostanme. -- sync_locations: Whether to sync locations from Nautobot to LibreNMS. -- location_type: This is used to filter which locations are synced to LibreNMS. This should be the Location Type that actually has devices assigned. For example, Site. Since LibreNMS does not support nested locations. -- tenant: This is used as a filter for objects synced with Nautobot and LibreNMS. This can be used to sync multiple LibreNMS instances into different tenants, like in an MSP environment. This affects which devices are loaded from Nautobot during the sync. It does not affect which devices are loaded from LibreNMS - force_add: Whether to force add devices to LibreNMS. This will bypass the ICMP check. Will not work correctly until SNMP credential support is added to the LibreNMSDataTarget job. - ping_fallback: Whether to add device as ping-only if device is not reachable via SNMP. From c857d0faac2a74d046220ec6bd64e41c33cc7b00 Mon Sep 17 00:00:00 2001 From: "Zach Biles @bile0026" Date: Tue, 21 Jan 2025 13:28:54 -0600 Subject: [PATCH 20/22] =?UTF-8?q?fix:=20=F0=9F=90=9B=20add=20tenant=20para?= =?UTF-8?q?meter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nautobot_ssot/integrations/librenms/jobs.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/nautobot_ssot/integrations/librenms/jobs.py b/nautobot_ssot/integrations/librenms/jobs.py index 8b40704c..5b8f1dd5 100644 --- a/nautobot_ssot/integrations/librenms/jobs.py +++ b/nautobot_ssot/integrations/librenms/jobs.py @@ -2,6 +2,7 @@ import os +from django.contrib.contenttypes.models import ContentType from django.templatetags.static import static from nautobot.apps.jobs import BooleanVar, ChoiceVar, ObjectVar from nautobot.core.celery import register_jobs @@ -52,7 +53,7 @@ class LibrenmsDataSource(DataSource): sync_locations = BooleanVar(description="Whether to Sync Locations from LibreNMS to Nautobot.", default=False) location_type = ObjectVar( model=LocationType, - queryset=LocationType.objects.all(), + queryset=LocationType.objects.filter(content_types=ContentType.objects.get(app_label="dcim", model="device")), display_field="name", required=False, label="Location Type", @@ -170,7 +171,7 @@ class LibrenmsDataTarget(DataTarget): sync_locations = BooleanVar(description="Whether to Sync Locations from Nautobot to LibreNMS.", default=False) location_type = ObjectVar( model=LocationType, - queryset=LocationType.objects.all(), + queryset=LocationType.objects.filter(content_types=ContentType.objects.get(app_label="dcim", model="device")), display_field="name", required=False, label="Location Type", @@ -249,6 +250,7 @@ def run( ping_fallback, sync_locations, location_type, + tenant, *args, **kwargs, ): # pylint: disable=arguments-differ @@ -260,6 +262,7 @@ def run( self.location_type = location_type self.hostname_field == "env_var" self.load_type == "api" + self.tenant = tenant self.debug = debug self.dryrun = dryrun self.memory_profiling = memory_profiling From 9b57a4ef804617f6db0f0b9af1e90ad42f5cb0df Mon Sep 17 00:00:00 2001 From: "Zach Biles @bile0026" Date: Tue, 21 Jan 2025 13:52:27 -0600 Subject: [PATCH 21/22] =?UTF-8?q?fix:=20=F0=9F=90=9B=20disable=20integatio?= =?UTF-8?q?n=20by=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- development/development.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/development/development.env b/development/development.env index 77a8d5e2..6382c930 100644 --- a/development/development.env +++ b/development/development.env @@ -118,7 +118,7 @@ NAUTOBOT_SSOT_ENABLE_SLURPIT="False" SLURPIT_HOST="https://sandbox.slurpit.io" -NAUTOBOT_SSOT_ENABLE_LIBRENMS="True" +NAUTOBOT_SSOT_ENABLE_LIBRENMS="False" NAUTOBOT_SSOT_LIBRENMS_SYSTEM_OF_RECORD="LibreNMS" NAUTOBOT_SSOT_LIBRENMS_HOSTNAME_FIELD="sysName" # hostname or sysName From 87bc4e3234e9b32bb35632c347aa009bca3bd157 Mon Sep 17 00:00:00 2001 From: "Zach Biles @bile0026" Date: Tue, 21 Jan 2025 14:15:18 -0600 Subject: [PATCH 22/22] =?UTF-8?q?fix:=20=F0=9F=90=9B=20fix=20queryset=20fo?= =?UTF-8?q?r=20location=5Ftypes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nautobot_ssot/integrations/librenms/jobs.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/nautobot_ssot/integrations/librenms/jobs.py b/nautobot_ssot/integrations/librenms/jobs.py index 5b8f1dd5..ac4dc9f3 100644 --- a/nautobot_ssot/integrations/librenms/jobs.py +++ b/nautobot_ssot/integrations/librenms/jobs.py @@ -2,7 +2,6 @@ import os -from django.contrib.contenttypes.models import ContentType from django.templatetags.static import static from nautobot.apps.jobs import BooleanVar, ChoiceVar, ObjectVar from nautobot.core.celery import register_jobs @@ -53,7 +52,8 @@ class LibrenmsDataSource(DataSource): sync_locations = BooleanVar(description="Whether to Sync Locations from LibreNMS to Nautobot.", default=False) location_type = ObjectVar( model=LocationType, - queryset=LocationType.objects.filter(content_types=ContentType.objects.get(app_label="dcim", model="device")), + queryset=LocationType.objects.all(), + query_params={"content_types": "dcim.device"}, display_field="name", required=False, label="Location Type", @@ -171,7 +171,8 @@ class LibrenmsDataTarget(DataTarget): sync_locations = BooleanVar(description="Whether to Sync Locations from Nautobot to LibreNMS.", default=False) location_type = ObjectVar( model=LocationType, - queryset=LocationType.objects.filter(content_types=ContentType.objects.get(app_label="dcim", model="device")), + queryset=LocationType.objects.all(), + query_params={"content_types": "dcim.device"}, display_field="name", required=False, label="Location Type",