Skip to content

Commit 81163b1

Browse files
committed
Merge branch 'develop'
2 parents 128fd08 + 3965b70 commit 81163b1

File tree

15 files changed

+343
-149
lines changed

15 files changed

+343
-149
lines changed

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# Changelog
22

3+
## v1.2.0 (2021-04-14)
4+
5+
### Added
6+
7+
- #33 - Now supports the Django parameters `--no-color` and `--force-color`
8+
9+
### Changed
10+
11+
- #29 - Improved formatting of log output, added dynamic progress bars using `tqdm` library
12+
13+
### Fixed
14+
15+
- #31 - Records containing outdated custom field data should now be updated successfully
16+
- #32 - Status objects should not show as changed when resyncing data
17+
18+
319
## v1.1.0 (2021-04-07)
420

521
### Added

development/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
ARG python_ver=3.7
22
FROM python:${python_ver}
33

4-
ARG nautobot_ver=1.0.0a1
4+
ARG nautobot_ver=1.0.0b3
55
ENV PYTHONUNBUFFERED=1 \
66
PATH="/root/.poetry/bin:$PATH" \
77
NAUTOBOT_CONFIG="/source/development/nautobot_config.py"

development/nautobot_config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@
216216
},
217217
"loggers": {
218218
"django": {"handlers": ["normal_console"], "level": "INFO"},
219-
"nautobot": {"handlers": ["verbose_console" if DEBUG else "normal_console"], "level": LOG_LEVEL},
219+
"nautobot": {"handlers": ["verbose_console" if DEBUG else "normal_console"], "level": "INFO"},
220220
"rq.worker": {"handlers": ["verbose_console" if DEBUG else "normal_console"], "level": LOG_LEVEL},
221221
},
222222
}

media/screenshot1.png

-21.4 KB
Loading

media/screenshot2.png

-89.3 KB
Loading

nautobot_netbox_importer/diffsync/adapters/abstract.py

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Abstract base DiffSync adapter class for code shared by NetBox and Nautobot adapters."""
22

33
from collections import defaultdict
4-
from typing import MutableMapping, Union
4+
from typing import Callable, MutableMapping, Union
55
from uuid import UUID
66

77
from diffsync import Diff, DiffSync, DiffSyncFlags, DiffSyncModel
@@ -133,6 +133,8 @@ class N2NDiffSync(DiffSync):
133133
"user", # Includes NetBox "userconfig" model as well
134134
"objectpermission",
135135
"token",
136+
"customfield",
137+
"customfieldchoice",
136138
# "status", Not synced, as these are hard-coded in NetBox and autogenerated in Nautobot
137139
# Need Tenant and TenantGroup before we can populate Sites
138140
"tenantgroup",
@@ -208,15 +210,12 @@ class N2NDiffSync(DiffSync):
208210
"webhook",
209211
"taggeditem",
210212
"jobresult",
211-
# Imported last so that any "required=True" CustomFields do not cause Nautobot to reject
212-
# NetBox records that predate the creation of those CustomFields
213-
"customfield",
214-
"customfieldchoice",
215213
)
216214

217-
def __init__(self, *args, **kwargs):
215+
def __init__(self, *args, verbosity: int = 0, **kwargs):
218216
"""Initialize this container, including its PK-indexed alternate data store."""
219217
super().__init__(*args, **kwargs)
218+
self.verbosity = verbosity
220219
self._data_by_pk = defaultdict(dict)
221220
self._sync_summary = None
222221

@@ -265,8 +264,8 @@ def make_model(self, diffsync_model, data):
265264
instance = diffsync_model(**data, diffsync=self)
266265
except ValidationError as exc:
267266
self.logger.error(
268-
"Invalid data according to internal data model. "
269-
"This may be an issue with your source data or may reflect a bug in this plugin.",
267+
"Invalid data according to internal data model",
268+
comment="This may be an issue with your source data or may reflect a bug in this plugin.",
270269
action="load",
271270
exception=str(exc),
272271
model=diffsync_model.get_type(),
@@ -278,19 +277,25 @@ def make_model(self, diffsync_model, data):
278277
except ObjectAlreadyExists:
279278
existing_instance = self.get(diffsync_model, instance.get_unique_id())
280279
self.logger.warning(
281-
"Apparent duplicate object encountered? "
282-
"This may be an issue with your source data or may reflect a bug in this plugin.",
280+
"Apparent duplicate object encountered?",
281+
comment="This may be an issue with your source data or may reflect a bug in this plugin.",
283282
duplicate_id=instance.get_identifiers(),
284283
model=diffsync_model.get_type(),
285284
pk_1=existing_instance.pk,
286285
pk_2=instance.pk,
287286
)
288287
return instance
289288

290-
def sync_from(self, source: DiffSync, diff_class: Diff = Diff, flags: DiffSyncFlags = DiffSyncFlags.NONE):
289+
def sync_from(
290+
self,
291+
source: DiffSync,
292+
diff_class: Diff = Diff,
293+
flags: DiffSyncFlags = DiffSyncFlags.NONE,
294+
callback: Callable[[str, int, int], None] = None,
295+
):
291296
"""Synchronize data from the given source DiffSync object into the current DiffSync object."""
292297
self._sync_summary = None
293-
return super().sync_from(source, diff_class=diff_class, flags=flags)
298+
return super().sync_from(source, diff_class=diff_class, flags=flags, callback=callback)
294299

295300
def sync_complete(
296301
self,

nautobot_netbox_importer/diffsync/adapters/nautobot.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
from uuid import UUID
44

5+
from diffsync import DiffSync
56
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel
67
from django.db import models
78
import structlog
89

10+
from nautobot_netbox_importer.diffsync.models.abstract import NautobotBaseModel
11+
from nautobot_netbox_importer.utils import ProgressBar
912
from .abstract import N2NDiffSync
10-
from ..models.abstract import NautobotBaseModel
1113

1214

1315
IGNORED_FIELD_CLASSES = (GenericRel, GenericForeignKey, models.ManyToManyRel, models.ManyToOneRel)
@@ -54,6 +56,10 @@ def load_model(self, diffsync_model, record): # pylint: disable=too-many-branch
5456
# What's the name of the model that this is a reference to?
5557
target_name = diffsync_model.fk_associations()[field.name]
5658

59+
if target_name == "status":
60+
data[field.name] = {"slug": self.status.nautobot_model().objects.get(pk=value).slug}
61+
continue
62+
5763
# Special case: for generic foreign keys, the target_name is actually the name of
5864
# another field on this record that describes the content-type of this foreign key id.
5965
# We flag this by starting the target_name string with a '*', as if this were C or something.
@@ -101,8 +107,36 @@ def load(self):
101107
self.logger.info("Loading data from Nautobot into DiffSync...")
102108
for modelname in ("contenttype", "permission", "status", *self.top_level):
103109
diffsync_model = getattr(self, modelname)
104-
self.logger.info(f"Loading all {modelname} records...")
105-
for instance in diffsync_model.nautobot_model().objects.all():
106-
self.load_model(diffsync_model, instance)
110+
if diffsync_model.nautobot_model().objects.exists():
111+
for instance in ProgressBar(
112+
diffsync_model.nautobot_model().objects.all(),
113+
total=diffsync_model.nautobot_model().objects.count(),
114+
desc=f"{modelname:<25}", # len("consoleserverporttemplate")
115+
verbosity=self.verbosity,
116+
):
117+
self.load_model(diffsync_model, instance)
107118

108119
self.logger.info("Data loading from Nautobot complete.")
120+
121+
def restore_required_custom_fields(self, source: DiffSync):
122+
"""Post-synchronization cleanup function to restore any 'required=True' custom field records."""
123+
self.logger.debug("Restoring the 'required=True' flag on any such custom fields")
124+
for customfield in source.get_all(source.customfield):
125+
if customfield.actual_required:
126+
# We don't want to change the DiffSync record's `required` flag, only the Nautobot record
127+
customfield.update_nautobot_record(
128+
customfield.nautobot_model(),
129+
ids=customfield.get_identifiers(),
130+
attrs={"required": True},
131+
multivalue_attrs={},
132+
)
133+
134+
def sync_complete(self, source: DiffSync, *args, **kwargs):
135+
"""Callback invoked after completing a sync operation in which changes occurred."""
136+
# During the sync, we intentionally marked all custom fields as "required=False"
137+
# so that we could sync records that predated the creation of said custom fields.
138+
# Now that we've updated all records that might contain custom field data,
139+
# only now can we re-mark any "required" custom fields as such.
140+
self.restore_required_custom_fields(source)
141+
142+
return super().sync_complete(source, *args, **kwargs)

nautobot_netbox_importer/diffsync/adapters/netbox.py

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66
from diffsync.enum import DiffSyncModelFlags
77
import structlog
88

9+
from nautobot_netbox_importer.diffsync.models.abstract import NautobotBaseModel
10+
from nautobot_netbox_importer.diffsync.models.validation import netbox_pk_to_nautobot_pk
11+
from nautobot_netbox_importer.utils import ProgressBar
912
from .abstract import N2NDiffSync
10-
from ..models.abstract import NautobotBaseModel
11-
from ..models.validation import netbox_pk_to_nautobot_pk
1213

1314

1415
class NetBox210DiffSync(N2NDiffSync):
@@ -103,18 +104,29 @@ def load_record(self, diffsync_model, record): # pylint: disable=too-many-branc
103104
else:
104105
self.logger.warning("No UserConfig found for User", username=data["username"], pk=record["pk"])
105106
data["config_data"] = {}
106-
elif diffsync_model == self.customfield and data["type"] == "select":
107-
# NetBox stores the choices for a "select" CustomField (NetBox has no "multiselect" CustomFields)
108-
# locally within the CustomField model, whereas Nautobot has a separate CustomFieldChoices model.
109-
# So we need to split the choices out into separate DiffSync instances.
110-
# Since "choices" is an ArrayField, we have to parse it from the JSON string
111-
# see also models.abstract.ArrayField
112-
for choice in json.loads(data["choices"]):
113-
self.make_model(
114-
self.customfieldchoice,
115-
{"pk": uuid4(), "field": netbox_pk_to_nautobot_pk("customfield", record["pk"]), "value": choice},
116-
)
117-
del data["choices"]
107+
elif diffsync_model == self.customfield:
108+
# Because marking a custom field as "required" doesn't automatically assign a value to pre-existing records,
109+
# we never want to enforce 'required=True' at import time as there may be otherwise valid records that predate
110+
# the creation of this field. Store it on a private field instead and we'll fix it up at the end.
111+
data["actual_required"] = data["required"]
112+
data["required"] = False
113+
114+
if data["type"] == "select":
115+
# NetBox stores the choices for a "select" CustomField (NetBox has no "multiselect" CustomFields)
116+
# locally within the CustomField model, whereas Nautobot has a separate CustomFieldChoices model.
117+
# So we need to split the choices out into separate DiffSync instances.
118+
# Since "choices" is an ArrayField, we have to parse it from the JSON string
119+
# see also models.abstract.ArrayField
120+
for choice in json.loads(data["choices"]):
121+
self.make_model(
122+
self.customfieldchoice,
123+
{
124+
"pk": uuid4(),
125+
"field": netbox_pk_to_nautobot_pk("customfield", record["pk"]),
126+
"value": choice,
127+
},
128+
)
129+
del data["choices"]
118130

119131
return self.make_model(diffsync_model, data)
120132

@@ -123,14 +135,18 @@ def load(self):
123135
self.logger.info("Loading imported NetBox source data into DiffSync...")
124136
for modelname in ("contenttype", "permission", *self.top_level):
125137
diffsync_model = getattr(self, modelname)
126-
self.logger.info(f"Loading all {modelname} records...")
127138
content_type_label = diffsync_model.nautobot_model()._meta.label_lower
128139
# Handle a NetBox vs Nautobot discrepancy - the Nautobot target model is 'users.user',
129140
# but the NetBox data export will have user records under the label 'auth.user'.
130141
if content_type_label == "users.user":
131142
content_type_label = "auth.user"
132-
for record in self.source_data:
133-
if record["model"] == content_type_label:
143+
records = [record for record in self.source_data if record["model"] == content_type_label]
144+
if records:
145+
for record in ProgressBar(
146+
records,
147+
desc=f"{modelname:<25}", # len("consoleserverporttemplate")
148+
verbosity=self.verbosity,
149+
):
134150
self.load_record(diffsync_model, record)
135151

136152
self.logger.info("Data loading from NetBox source data complete.")

nautobot_netbox_importer/diffsync/models/abstract.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,9 @@ def fk_associations(cls):
7575
"""Get the mapping between foreign key (FK) fields and the corresponding DiffSync models they reference."""
7676
return cls._fk_associations
7777

78-
@staticmethod
78+
@classmethod
7979
def _get_nautobot_record(
80-
diffsync_model: DiffSyncModel, diffsync_value: Any, fail_quiet: bool = False
80+
cls, diffsync_model: DiffSyncModel, diffsync_value: Any, fail_quiet: bool = False
8181
) -> Optional[models.Model]:
8282
"""Given a diffsync model and identifier (natural key or primary key) look up the Nautobot record."""
8383
try:
@@ -93,6 +93,7 @@ def _get_nautobot_record(
9393
log = logger.debug if fail_quiet else logger.error
9494
log(
9595
"Expected but did not find an existing Nautobot record",
96+
source=cls.get_type(),
9697
target=diffsync_model.get_type(),
9798
unique_id=diffsync_value,
9899
)
@@ -186,6 +187,7 @@ def clean_attrs(cls, diffsync: DiffSync, attrs: dict) -> Tuple[dict, dict]:
186187
@staticmethod
187188
def create_nautobot_record(nautobot_model, ids: Mapping, attrs: Mapping, multivalue_attrs: Mapping):
188189
"""Helper method to create() - actually populate Nautobot data."""
190+
model_data = dict(**ids, **attrs, **multivalue_attrs)
189191
try:
190192
# Custom fields are a special case - because in NetBox the values defined on a particular record are
191193
# only loosely coupled to the CustomField definition itself, it's quite possible that these two may be
@@ -208,23 +210,23 @@ def create_nautobot_record(nautobot_model, ids: Mapping, attrs: Mapping, multiva
208210
action="create",
209211
exception=str(exc),
210212
model=nautobot_model,
211-
model_data=dict(**ids, **attrs, **multivalue_attrs),
213+
model_data=model_data,
212214
)
213215
except DjangoValidationError as exc:
214216
logger.error(
215217
"Nautobot reported a data validation error - check your source data",
216218
action="create",
217219
exception=str(exc),
218220
model=nautobot_model,
219-
model_data=dict(**ids, **attrs, **multivalue_attrs),
221+
model_data=model_data,
220222
)
221223
except ObjectDoesNotExist as exc: # Including RelatedObjectDoesNotExist
222224
logger.error(
223225
"Nautobot reported an error about a missing required object",
224226
action="create",
225227
exception=str(exc),
226228
model=nautobot_model,
227-
model_data=dict(**ids, **attrs, **multivalue_attrs),
229+
model_data=model_data,
228230
)
229231

230232
return None
@@ -272,9 +274,17 @@ def create(cls, diffsync: DiffSync, ids: Mapping, attrs: Mapping) -> Optional["N
272274
@staticmethod
273275
def update_nautobot_record(nautobot_model, ids: Mapping, attrs: Mapping, multivalue_attrs: Mapping):
274276
"""Helper method to update() - actually update Nautobot data."""
277+
model_data = dict(**ids, **attrs, **multivalue_attrs)
275278
try:
276279
record = nautobot_model.objects.get(**ids)
277280
custom_field_data = attrs.pop("custom_field_data", None)
281+
# Temporarily clear any existing custom field data as part of the model update,
282+
# so that in case the model contains "stale" data referring to no-longer-existent fields,
283+
# Nautobot won't reject it out of hand.
284+
if not custom_field_data and hasattr(record, "custom_field_data"):
285+
custom_field_data = record.custom_field_data
286+
if custom_field_data:
287+
record._custom_field_data = {} # pylint: disable=protected-access
278288
for attr, value in attrs.items():
279289
setattr(record, attr, value)
280290
record.clean()
@@ -291,23 +301,23 @@ def update_nautobot_record(nautobot_model, ids: Mapping, attrs: Mapping, multiva
291301
action="update",
292302
exception=str(exc),
293303
model=nautobot_model,
294-
model_data=dict(**ids, **attrs, **multivalue_attrs),
304+
model_data=model_data,
295305
)
296306
except DjangoValidationError as exc:
297307
logger.error(
298308
"Nautobot reported a data validation error - check your source data",
299309
action="update",
300310
exception=str(exc),
301311
model=nautobot_model,
302-
model_data=dict(**ids, **attrs, **multivalue_attrs),
312+
model_data=model_data,
303313
)
304314
except ObjectDoesNotExist as exc: # Including RelatedObjectDoesNotExist
305315
logger.error(
306316
"Nautobot reported an error about a missing required object",
307317
action="update",
308318
exception=str(exc),
309319
model=nautobot_model,
310-
model_data=dict(**ids, **attrs, **multivalue_attrs),
320+
model_data=model_data,
311321
)
312322

313323
return None

nautobot_netbox_importer/diffsync/models/extras.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,11 @@ class CustomField(NautobotBaseModel):
114114
validation_maximum: Optional[int]
115115
validation_regex: str
116116

117+
# Because marking a custom field as "required" doesn't automatically assign a value to pre-existing records,
118+
# we never want, when adding custom fields from NetBox, to flag fields as required=True.
119+
# Instead we store it in "actual_required" and fix it up only afterwards.
120+
actual_required: Optional[bool]
121+
117122
@classmethod
118123
def special_clean(cls, diffsync, ids, attrs):
119124
"""Special-case handling for the "default" attribute."""

0 commit comments

Comments
 (0)