Skip to content

Commit 42207ac

Browse files
authored
Merge pull request #900 from nautobot/release-v3.9.1
Release v3.9.1
2 parents bf39993 + 0b8f8b3 commit 42207ac

File tree

8 files changed

+111
-23
lines changed

8 files changed

+111
-23
lines changed

docs/admin/integrations/servicenow_setup.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,23 @@ PLUGINS_CONFIG = {
4040
!!! note
4141
All integration settings are defined in the block above as an example. Only some will be needed as described below.
4242

43+
## Duplicates
44+
45+
When duplicates records are encountered in ServiceNow this is problematic for Nautobot to identify the correct record to update. The ServiceNow SSOT sync logic will warn you about these duplicate instances but it is up to the end-user to reconcile them for accurate data syncronization.
46+
47+
At the end of an SSOT run, for every ServiceNow table where duplicates were found - a corresponding `duplicate_${table}.txt` file will be present in the results. This is in the format of a CSV file with the top row containing the attribute name and each subsequent row being the element that was found in duplicate.
48+
49+
For example, if multiple product models were discovered you'll see a log warning and a file called `duplicate_product_model.txt` in the SSOT run output with contents such as:
50+
51+
```
52+
manufacturer_name,model_name,model_number
53+
Cisco,Catalyst 9300,C9300-48P
54+
Dell,PowerEdge R740,R740-8SFF
55+
HP,ProLiant DL360,DL360-G10
56+
Juniper,EX4300,EX4300-48P
57+
Arista,7050X3,DCS-7050X3-32S
58+
```
59+
4360
## Upgrading from `nautobot-plugin-ssot-servicenow` App
4461

4562
!!! warning

docs/admin/release_notes/version_3.9.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ This release brings several significant additions and changes:
1313
- The example Jobs now support synchronizing Tags on appropriate objects between Nautobot instances.
1414
- All integrations that utilize the contrib pattern will automatically support [Object Metadata](https://docs.nautobot.com/projects/core/en/stable/user-guide/platform-functionality/objectmetadata/) being added to their models.
1515

16-
## [v3.9.0 (2025-06-20)](https://github.com/nautobot/nautobot-app-ssot/releases/tag/v3.9.0)
16+
## [v3.9.0 (2025-06-25)](https://github.com/nautobot/nautobot-app-ssot/releases/tag/v3.9.0)
1717

1818
### Added
1919

@@ -50,3 +50,21 @@ This release brings several significant additions and changes:
5050

5151
- [#856](https://github.com/nautobot/nautobot-app-ssot/issues/856) - Added a note to the developer upgrade documentation to explain the default value for text fields declared with `blank=True, null=False`.
5252
- [#870](https://github.com/nautobot/nautobot-app-ssot/issues/870) - Updated installation steps for vSphere integration.
53+
54+
## [v3.9.1 (2025-07-09)](https://github.com/nautobot/nautobot-app-ssot/releases/tag/v3.9.1)
55+
56+
### Release Overview
57+
58+
Please note that the behavior in the SNOW integration now is to swallow and log an overview of how many duplicates encountered, and provide file output outlining what duplicates were encountered.
59+
60+
### Changed
61+
62+
- [#874](https://github.com/nautobot/nautobot-app-ssot/issues/874) - Reverted changes in `NautobotModel` to be backwards compatible with other integrations.
63+
- [#874](https://github.com/nautobot/nautobot-app-ssot/issues/874) - Reverted removal of `invalidate_cache` method in `NautobotAdapter`.
64+
65+
### Fixed
66+
67+
- [#844](https://github.com/nautobot/nautobot-app-ssot/issues/844) - Fixed job failure if there are duplicate devices in LibreNMS. Will skip device instead.
68+
- [#867](https://github.com/nautobot/nautobot-app-ssot/issues/867) - Gracefully swallow ServiceNow exceptions
69+
- [#867](https://github.com/nautobot/nautobot-app-ssot/issues/867) - Adds ServiceNow duplicate file reports
70+
- [#867](https://github.com/nautobot/nautobot-app-ssot/issues/867) - Fixes ServiceNow comparison filters to only compare against company names with Manufacturer set to True

nautobot_ssot/contrib/adapter.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ def _get_parameter_names(diffsync_model):
5050
"""Ignore the differences between identifiers and attributes, because at this point they don't matter to us."""
5151
return list(diffsync_model._identifiers) + list(diffsync_model._attributes) # pylint: disable=protected-access
5252

53+
def invalidate_cache(self, zero_out_hits=True):
54+
"""Deprecated, kept for backwards compatibility."""
55+
self.job.logger.warning(
56+
"Adapter class method `self.invalidate_cache()` is deprecated and will be removed in a future version. "
57+
"Use `self.cache.invalidate_cache()` instead."
58+
)
59+
self.cache.invalidate_cache(zero_out_hits=zero_out_hits)
60+
5361
def _load_objects(self, diffsync_model):
5462
"""Given a diffsync model class, load a list of models from the database and return them."""
5563
parameter_names = self._get_parameter_names(diffsync_model)

nautobot_ssot/contrib/model.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def _check_field(cls, name):
6767
def get_from_db(self):
6868
"""Get the ORM object for this diffsync object from the database using the primary key."""
6969
try:
70-
return self.adapter.cache.get_from_orm(self._model, {"pk": self.pk})
70+
return self.adapter.get_from_orm_cache({"pk": self.pk}, self._model)
7171
except self._model.DoesNotExist as error:
7272
raise ObjectCrudException(f"No such {self._model._meta.verbose_name} instance with PK {self.pk}") from error
7373

@@ -172,7 +172,7 @@ def _handle_single_field(cls, field, obj, value, relationship_fields, adapter):
172172

173173
# Prepare handling of custom relationship many-to-many fields.
174174
if custom_relationship_annotation:
175-
relationship = adapter.cache.get_from_orm(Relationship, {"label": custom_relationship_annotation.name})
175+
relationship = adapter.get_from_orm_cache({"label": custom_relationship_annotation.name}, Relationship)
176176
if custom_relationship_annotation.side == RelationshipSideEnum.DESTINATION:
177177
related_object_content_type = relationship.source_type
178178
else:
@@ -188,8 +188,8 @@ def _handle_single_field(cls, field, obj, value, relationship_fields, adapter):
188188
}
189189
else:
190190
relationship_fields["custom_relationship_many_to_many_fields"][field] = {
191-
"objects": [adapter.cache.get_from_orm(related_model_class, parameters) for parameters in value],
192191
"annotation": custom_relationship_annotation,
192+
"objects": [adapter.get_from_orm_cache(parameters, related_model_class) for parameters in value],
193193
}
194194

195195
return
@@ -201,7 +201,7 @@ def _handle_single_field(cls, field, obj, value, relationship_fields, adapter):
201201
if django_field.many_to_many or django_field.one_to_many:
202202
try:
203203
relationship_fields["many_to_many_fields"][field] = [
204-
adapter.cache.get_from_orm(django_field.related_model, parameters) for parameters in value
204+
adapter.get_from_orm_cache(parameters, django_field.related_model) for parameters in value
205205
]
206206
except django_field.related_model.DoesNotExist as error:
207207
raise ObjectCrudException(
@@ -261,7 +261,7 @@ def _set_custom_relationship_to_many_fields(cls, custom_relationship_many_to_man
261261
annotation = dictionary.pop("annotation")
262262
objects = dictionary.pop("objects")
263263
# TODO: Deduplicate this code
264-
relationship = adapter.cache.get_from_orm(Relationship, {"label": annotation.name})
264+
relationship = adapter.get_from_orm_cache({"label": annotation.name}, Relationship)
265265
parameters = {
266266
"relationship": relationship,
267267
"source_type": relationship.source_type,
@@ -274,7 +274,7 @@ def _set_custom_relationship_to_many_fields(cls, custom_relationship_many_to_man
274274
association_parameters = parameters.copy()
275275
association_parameters["destination_id"] = object_to_relate.id
276276
try:
277-
association = adapter.cache.get_from_orm(RelationshipAssociation, association_parameters)
277+
association = adapter.get_from_orm_cache(association_parameters, RelationshipAssociation)
278278
except RelationshipAssociation.DoesNotExist:
279279
association = RelationshipAssociation(**parameters, destination_id=object_to_relate.id)
280280
association.validated_save()
@@ -285,7 +285,7 @@ def _set_custom_relationship_to_many_fields(cls, custom_relationship_many_to_man
285285
association_parameters = parameters.copy()
286286
association_parameters["source_id"] = object_to_relate.id
287287
try:
288-
association = adapter.cache.get_from_orm(RelationshipAssociation, association_parameters)
288+
association = adapter.get_from_orm_cache(association_parameters, RelationshipAssociation)
289289
except RelationshipAssociation.DoesNotExist:
290290
association = RelationshipAssociation(**parameters, source_id=object_to_relate.id)
291291
association.validated_save()
@@ -321,7 +321,7 @@ def _lookup_and_set_custom_relationship_foreign_keys(cls, custom_relationship_fo
321321
annotation = related_model_dict.pop("_annotation")
322322
# TODO: Deduplicate this code
323323
try:
324-
relationship = adapter.cache.get_from_orm(Relationship, {"label": annotation.name})
324+
relationship = adapter.get_from_orm_cache({"label": annotation.name}, Relationship)
325325
except Relationship.DoesNotExist as error:
326326
raise ObjectCrudException(f"No such relationship with label '{annotation.name}'") from error
327327
parameters = {
@@ -333,7 +333,7 @@ def _lookup_and_set_custom_relationship_foreign_keys(cls, custom_relationship_fo
333333
parameters["source_id"] = obj.id
334334
related_model_class = relationship.destination_type.model_class()
335335
try:
336-
destination_object = adapter.cache.get_from_orm(related_model_class, related_model_dict)
336+
destination_object = adapter.get_from_orm_cache(related_model_dict, related_model_class)
337337
except related_model_class.DoesNotExist as error:
338338
raise ObjectCrudException(
339339
f"Couldn't resolve custom relationship {relationship.name}, no such {related_model_class._meta.verbose_name} object with parameters {related_model_dict}."
@@ -348,7 +348,7 @@ def _lookup_and_set_custom_relationship_foreign_keys(cls, custom_relationship_fo
348348
)
349349
else:
350350
parameters["destination_id"] = obj.id
351-
source_object = adapter.cache.get_from_orm(relationship.source_type.model_class(), related_model_dict)
351+
source_object = adapter.get_from_orm_cache(related_model_dict, relationship.source_type.model_class())
352352
RelationshipAssociation.objects.update_or_create(**parameters, defaults={"source_id": source_object.id})
353353

354354
@classmethod
@@ -377,8 +377,8 @@ def _lookup_and_set_foreign_keys(cls, foreign_keys, obj, adapter):
377377
f"for generic foreign keys."
378378
) from error
379379
try:
380-
related_model_content_type = adapter.cache.get_from_orm(
381-
ContentType, {"app_label": app_label, "model": model}
380+
related_model_content_type = adapter.get_from_orm_cache(
381+
{"app_label": app_label, "model": model}, ContentType
382382
)
383383
related_model = related_model_content_type.model_class()
384384
except ContentType.DoesNotExist as error:
@@ -388,7 +388,7 @@ def _lookup_and_set_foreign_keys(cls, foreign_keys, obj, adapter):
388388
setattr(obj, field_name, None)
389389
continue
390390
try:
391-
related_object = adapter.cache.get_from_orm(related_model, related_model_dict)
391+
related_object = adapter.get_from_orm_cache(related_model_dict, related_model)
392392
except related_model.DoesNotExist as error:
393393
raise ObjectCrudException(
394394
f"Couldn't find '{related_model._meta.verbose_name}' instance behind '{field_name}' with: {related_model_dict}."

nautobot_ssot/integrations/librenms/diffsync/adapters/librenms.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import os
44

55
from diffsync import DiffSync
6-
from diffsync.exceptions import ObjectNotFound
6+
from diffsync.exceptions import ObjectAlreadyExists, ObjectNotFound
77
from django.contrib.contenttypes.models import ContentType
88
from nautobot.dcim.models import Location, LocationType
99
from nautobot.extras.models import Status
@@ -99,7 +99,10 @@ def load_device(self, device: dict):
9999
ip_address=device["ip"],
100100
system_of_record=os.getenv("NAUTOBOT_SSOT_LIBRENMS_SYSTEM_OF_RECORD", "LibreNMS"),
101101
)
102-
self.add(new_device)
102+
try:
103+
self.add(new_device)
104+
except ObjectAlreadyExists:
105+
self.job.logger.warning(f"Device {device[self.hostname_field]} already exists. Skipping.")
103106
else:
104107
self.job.logger.info(f'Device {device[self.hostname_field]} is "ping-only". Skipping.')
105108

nautobot_ssot/integrations/servicenow/diffsync/adapter_servicenow.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@
1212
from diffsync.exceptions import ObjectAlreadyExists, ObjectNotFound
1313
from jinja2 import Environment, FileSystemLoader
1414

15+
from nautobot_ssot.contrib.model import DiffSyncModel
16+
1517
from . import models
1618

1719

18-
class ServiceNowDiffSync(Adapter):
20+
class ServiceNowDiffSync(Adapter): # pylint: disable=too-many-instance-attributes
1921
"""DiffSync adapter using pysnow to communicate with a ServiceNow server."""
2022

2123
# create defaultdict object to store objects that should be deleted from ServiceNow if they do not
@@ -50,6 +52,8 @@ def __init__(self, *args, client=None, job=None, sync=None, site_filter=None, **
5052
# create all of these interfaces in a single API call.
5153
self.interfaces_to_create_per_device = {}
5254

55+
self.duplicate_records = defaultdict(list) # Store duplicate records for user notification
56+
5357
def load(self):
5458
"""Load data via pysnow."""
5559
self.mapping_data = self.load_yaml_datafile("mappings.yaml")
@@ -102,6 +106,9 @@ def load(self):
102106
else:
103107
self.load_table(modelname, **entry)
104108

109+
for modelname, duplicate_uids in self.duplicate_records.items():
110+
self.job.create_file(f"duplicate_{modelname}.txt", "\n".join(duplicate_uids))
111+
105112
@classmethod
106113
def load_yaml_datafile(cls, filename, config=None):
107114
"""Get the contents of the given YAML data file.
@@ -133,23 +140,35 @@ def load_table(self, modelname, table, mappings, **kwargs):
133140
"""
134141
model_cls = getattr(self, modelname)
135142
self.job.logger.info(f"Loading ServiceNow table `{table}` into {modelname} instances...")
143+
table_query_filter = kwargs.get("table_query", {})
136144

137145
if "parent" not in kwargs:
138146
# Load the entire table
139-
for record in self.client.all_table_entries(table):
147+
for record in self.client.all_table_entries(table, table_query_filter):
140148
self.load_record(table, record, model_cls, mappings, **kwargs)
141149
else:
142150
# Load items per parent object that we know/care about
143151
# This is necessary because, for example, the cmdb_ci_network_adapter table contains network interfaces
144152
# for ALL types of devices (servers, switches, firewalls, etc.) but we only have switches as parent objects
145153
for parent in self.get_all(kwargs["parent"]["modelname"]):
146-
for record in self.client.all_table_entries(table, {kwargs["parent"]["column"]: parent.sys_id}):
154+
table_query_filter[kwargs["parent"]["column"]] = parent.sys_id
155+
for record in self.client.all_table_entries(table, table_query_filter):
147156
self.load_record(table, record, model_cls, mappings, **kwargs)
157+
if self.duplicate_records.get(modelname, False):
158+
self.job.logger.warning(f"Found {len(self.duplicate_records[modelname])} duplicate {modelname} record(s).")
148159

149160
self.job.logger.info(
150161
f"Loaded {len(self.get_all(modelname))} {modelname} records from ServiceNow table `{table}`."
151162
)
152163

164+
def log_duplicate_records(self, model_cls: DiffSyncModel) -> None:
165+
"""Capture duplicate records in CSV format that were found during the ServiceNow record load."""
166+
model_name = model_cls._modelname # pylint: disable=protected-access
167+
model_identifiers = model_cls._identifiers # pylint: disable=protected-access
168+
if not self.duplicate_records.get(model_name, False):
169+
self.duplicate_records[model_name] = [",".join(model_identifiers)]
170+
self.duplicate_records[model_name].append(",".join([getattr(model_cls, attr) for attr in model_identifiers]))
171+
153172
def load_record(self, table, record, model_cls, mappings, **kwargs):
154173
"""Helper method to load_table()."""
155174
self.sys_ids.setdefault(table, {})[record["sys_id"]] = record
@@ -163,7 +182,7 @@ def load_record(self, table, record, model_cls, mappings, **kwargs):
163182
except ObjectAlreadyExists:
164183
# The baseline data in a standard ServiceNow developer instance has a number of duplicate Location entries.
165184
# For now, ignore the duplicate entry and continue
166-
self.job.logger.warning(f'Ignoring apparent duplicate record for {modelname} "{model.get_unique_id()}".')
185+
self.log_duplicate_records(model)
167186

168187
if "parent" in kwargs:
169188
parent_uid = getattr(model, kwargs["parent"]["field"])
@@ -174,7 +193,10 @@ def load_record(self, table, record, model_cls, mappings, **kwargs):
174193
)
175194
else:
176195
parent_model = self.get(kwargs["parent"]["modelname"], parent_uid)
177-
parent_model.add_child(model)
196+
try:
197+
parent_model.add_child(model)
198+
except ObjectAlreadyExists:
199+
pass
178200

179201
return model
180202

nautobot_ssot/tests/servicenow/test_adapter_servicenow.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Unit tests for the ServiceNowDiffSync adapter class."""
22

3+
from collections import defaultdict
4+
35
from nautobot.core.testing import TransactionTestCase
46
from nautobot.extras.models import JobResult
57

@@ -10,13 +12,18 @@
1012
class MockServiceNowClient:
1113
"""Mock version of the ServiceNowClient class using canned data."""
1214

15+
def __init__(self):
16+
self.query_params = defaultdict(list)
17+
1318
def get_by_sys_id(self, table, sys_id): # pylint: disable=unused-argument
1419
"""Get a record with a given sys_id from a given table."""
1520
return None
1621

17-
def all_table_entries(self, table, query=None):
22+
def all_table_entries(self, table, query={}): # pylint: disable=dangerous-default-value
1823
"""Iterator over all records in a given table."""
1924

25+
self.query_params[table].append(query)
26+
2027
if table == "cmn_location":
2128
yield from [
2229
{
@@ -611,3 +618,16 @@ def test_data_loading(self):
611618
["hkg-leaf-01__Ethernet1", "hkg-leaf-01__Ethernet2"],
612619
sorted(intf.get_unique_id() for intf in snds.get_all("interface")),
613620
)
621+
622+
def test_filtering(self):
623+
"""Want to ensure our table query filtering is passed through correctly.
624+
625+
In the mappings yaml, we have a table filter for company to only grab records with manufacturer=True.
626+
"""
627+
job = self.job_class()
628+
job.job_result = JobResult.objects.create(name=job.class_path, task_name="fake task", worker="default")
629+
mock_snow_client = MockServiceNowClient()
630+
snds = ServiceNowDiffSync(job=job, sync=None, client=mock_snow_client)
631+
632+
snds.load()
633+
self.assertEqual(mock_snow_client.query_params["core_company"], [{"manufacturer": True}])

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "nautobot-ssot"
3-
version = "3.9.0"
3+
version = "3.9.1"
44
description = "Nautobot Single Source of Truth"
55
authors = ["Network to Code, LLC <opensource@networktocode.com>"]
66
license = "Apache-2.0"

0 commit comments

Comments
 (0)