Skip to content

Commit 2b7567c

Browse files
committed
feat: Cables, circuits and cable terminations
1 parent 9456809 commit 2b7567c

File tree

6 files changed

+299
-18
lines changed

6 files changed

+299
-18
lines changed

nautobot_netbox_importer/diffsync/adapters/netbox.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@
1111
from django.db.transaction import atomic
1212

1313
from nautobot_netbox_importer.base import GENERATOR_SETUP_MODULES, logger, register_generator_setup
14+
from nautobot_netbox_importer.diffsync.models.cables import create_missing_cable_terminations
1415
from nautobot_netbox_importer.diffsync.models.dcim import fix_power_feed_locations, unrack_zero_uheight_devices
1516
from nautobot_netbox_importer.generator import SourceAdapter, SourceDataGenerator, SourceRecord
1617
from nautobot_netbox_importer.summary import Pathable
1718

1819
for _name in (
1920
"base",
21+
"cables",
2022
"circuits",
2123
"content_types",
2224
"custom_fields",
@@ -51,6 +53,7 @@ class NetBoxImporterOptions(NamedTuple):
5153
deduplicate_prefixes: bool = False
5254
fix_powerfeed_locations: bool = False
5355
sitegroup_parent_always_region: bool = False
56+
create_missing_cable_terminations: bool = False
5457
tag_issues: str = ""
5558
unrack_zero_uheight_devices: bool = True
5659
save_json_summary_path: str = ""
@@ -96,6 +99,9 @@ def load(self) -> None:
9699
if self.options.unrack_zero_uheight_devices:
97100
unrack_zero_uheight_devices(self)
98101

102+
if self.options.create_missing_cable_terminations:
103+
create_missing_cable_terminations(self)
104+
99105
self.post_load()
100106

101107
def import_to_nautobot(self) -> None:
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
# COMPATIBLE_TERMINATION_TYPES = {
2+
# "circuittermination": ["interface", "frontport", "rearport", "circuittermination"],
3+
# "consoleport": ["consoleserverport", "frontport", "rearport"],
4+
# "consoleserverport": ["consoleport", "frontport", "rearport"],
5+
# "interface": ["interface", "circuittermination", "frontport", "rearport"],
6+
# "frontport": [
7+
# "consoleport",
8+
# "consoleserverport",
9+
# "interface",
10+
# "frontport",
11+
# "rearport",
12+
# "circuittermination",
13+
# ],
14+
# "powerfeed": ["powerport"],
15+
# "poweroutlet": ["powerport"],
16+
# "powerport": ["poweroutlet", "powerfeed"],
17+
# "rearport": [
18+
# "consoleport",
19+
# "consoleserverport",
20+
# "interface",
21+
# "frontport",
22+
# "rearport",
23+
# "circuittermination",
24+
# ],
25+
# }
26+
"""DCIM data related functions."""
27+
28+
from diffsync import DiffSyncModel
29+
from nautobot.dcim.constants import COMPATIBLE_TERMINATION_TYPES
30+
31+
from nautobot_netbox_importer.base import DUMMY_UID, ContentTypeStr, RecordData, Uid
32+
from nautobot_netbox_importer.generator import (
33+
EMPTY_VALUES,
34+
DiffSyncBaseModel,
35+
PreImportRecordResult,
36+
SourceAdapter,
37+
SourceField,
38+
SourceModelWrapper,
39+
)
40+
41+
_FALLBACK_TERMINATION_TYPE = "circuittermination"
42+
_CIRCUIT_MODELS = {"circuittermination"}
43+
_IGNORE_CABLE_LABELS = (
44+
"connected",
45+
"testing",
46+
"planned",
47+
"decommissioned",
48+
"disconnected",
49+
"failed",
50+
"unknown",
51+
)
52+
53+
54+
def _pre_import_cable_termination(source: RecordData, _) -> PreImportRecordResult:
55+
cable_end = source.pop("cable_end").lower()
56+
source["id"] = source.pop("cable")
57+
source[f"termination_{cable_end}_type"] = source.pop("termination_type")
58+
source[f"termination_{cable_end}_id"] = source.pop("termination_id")
59+
60+
return PreImportRecordResult.USE_RECORD
61+
62+
63+
def _define_cable_label(field: SourceField) -> None:
64+
"""Define the cable label field importer.
65+
66+
Importer uses cable.id if label is empty or contains any of the ignored labels.
67+
"""
68+
69+
def cable_label_importer(source: RecordData, target: DiffSyncBaseModel) -> None:
70+
value = field.get_source_value(source)
71+
72+
if value:
73+
value = str(value).strip()
74+
75+
if value in EMPTY_VALUES or value.lower() in _IGNORE_CABLE_LABELS:
76+
value = str(source["id"])
77+
78+
field.set_nautobot_value(target, value)
79+
80+
field.set_importer(cable_label_importer)
81+
82+
83+
def _get_first_compatible_termination_type(stripped_type: str) -> ContentTypeStr:
84+
"""Determine the first compatible termination type for a given termination.
85+
86+
This function identifies the first compatible termination type based on the
87+
given termination string, falling back to '_FALLBACK_TERMINATION_TYPE' if
88+
no compatibility is found.
89+
90+
Args:
91+
stripped_type (str): The termination type with 'dcim.' prefix removed
92+
93+
Returns:
94+
str: The compatible termination type with 'dcim.' prefix
95+
96+
Examples:
97+
>>> _get_first_compatible_termination_type("interface")
98+
'dcim.interface'
99+
100+
>>> _get_first_compatible_termination_type("poweroutlet")
101+
'dcim.powerport'
102+
103+
>>> _get_first_compatible_termination_type("unknown")
104+
'circuits.circuittermination'
105+
"""
106+
107+
def get_type(model_name: str) -> ContentTypeStr:
108+
return f"circuits.{model_name}" if model_name in _CIRCUIT_MODELS else f"dcim.{model_name}"
109+
110+
if stripped_type not in COMPATIBLE_TERMINATION_TYPES:
111+
return get_type(_FALLBACK_TERMINATION_TYPE)
112+
113+
types = COMPATIBLE_TERMINATION_TYPES[stripped_type]
114+
if _FALLBACK_TERMINATION_TYPE in types:
115+
return get_type(_FALLBACK_TERMINATION_TYPE)
116+
117+
return get_type(types[0])
118+
119+
120+
def _get_termination(uid: Uid, type_: ContentTypeStr, other_type: ContentTypeStr) -> tuple[Uid, ContentTypeStr]:
121+
"""Determine the appropriate termination for a cable side.
122+
123+
This function evaluates cable termination data and returns correct termination information
124+
based on compatibility rules.
125+
126+
Args:
127+
uid (Uid): UID of the current termination
128+
type_ (ContentTypeStr): Type of the current termination
129+
other_type (ContentTypeStr): Type of the opposite termination
130+
131+
Returns:
132+
tuple[str, str]: A tuple containing (termination_id, termination_type)
133+
134+
Examples:
135+
>>> _get_termination("123", "dcim.interface", "dcim.frontport")
136+
('123', 'dcim.interface')
137+
138+
>>> _get_termination("", "dcim.interface", "dcim.poweroutlet")
139+
('dummy', 'circuit.circuittermination')
140+
141+
>>> _get_termination("123", "", "dcim.frontport")
142+
('123', 'dcim.interface')
143+
144+
>>> _get_termination("123", "dcim.interface", "")
145+
('123', 'dcim.interface')
146+
147+
>>> _get_termination("", "", "")
148+
('dummy', 'circuit.circuittermination')
149+
150+
>>> _get_termination("456", "dcim.powerport", "dcim.poweroutlet")
151+
('456', 'dcim.powerport')
152+
"""
153+
type_stripped = type_.split(".")[1] if type_ else ""
154+
other_stripped = other_type.split(".")[1] if other_type else ""
155+
first_compatible = _get_first_compatible_termination_type(other_stripped)
156+
157+
if not type_:
158+
uid = DUMMY_UID
159+
type_ = first_compatible
160+
161+
if not uid:
162+
uid = DUMMY_UID
163+
164+
if not other_type:
165+
return uid, type_
166+
167+
if type_stripped in COMPATIBLE_TERMINATION_TYPES and other_stripped in COMPATIBLE_TERMINATION_TYPES.get(
168+
type_stripped, []
169+
):
170+
return uid, type_
171+
172+
return DUMMY_UID, first_compatible
173+
174+
175+
def _update_cable_termination(wrapper: SourceModelWrapper, cable: DiffSyncModel, side: str) -> None:
176+
"""Update cable termination information for a specific side.
177+
178+
This function retrieves termination data for the specified side of a cable, determines
179+
the appropriate termination using _get_termination(), and updates the cable if needed.
180+
181+
Args:
182+
wrapper (SourceModelWrapper): Model wrapper containing field definitions
183+
cable (DiffSyncModel): The cable model to update
184+
side (str): Which side of the cable to update ('a' or 'b')
185+
"""
186+
adapter = wrapper.adapter
187+
188+
old_uid = getattr(cable, f"termination_{side}_id", "")
189+
old_type_id = getattr(cable, f"termination_{side}_type_id", 0)
190+
old_type = adapter.nautobot.get_content_type_str(old_type_id) if old_type_id else ""
191+
other_type = getattr(cable, f"termination_{'b' if side == 'a' else 'a'}_type", "")
192+
193+
new_uid, new_type = _get_termination(old_uid, old_type, other_type)
194+
195+
if new_uid == old_uid and new_type == old_type:
196+
return
197+
198+
source_field = wrapper.fields[f"termination_{side}_type"]
199+
source_field.set_nautobot_value(cable, adapter.get_nautobot_content_type_uid(new_type))
200+
201+
if new_uid == DUMMY_UID:
202+
type_wrapper = adapter.get_or_create_wrapper(new_type)
203+
new_uid = type_wrapper.cache_dummy_object(
204+
f"_dcim.cable_{getattr(cable, wrapper.nautobot.pk_field.name)}_side_{side}"
205+
)
206+
cable_id = type_wrapper.get_pk_from_uid(new_uid)
207+
wrapper.add_reference(type_wrapper, cable_id)
208+
209+
source_field = wrapper.fields[f"termination_{side}_id"]
210+
source_field.set_nautobot_value(cable, cable_id)
211+
212+
source_field.add_issue(
213+
"UpdatedCableTermination",
214+
f"Cable termination {side.upper()} updated from {old_uid}, {old_type} to {new_uid}, {new_type}",
215+
cable,
216+
)
217+
218+
219+
def create_missing_cable_terminations(adapter: SourceAdapter) -> None:
220+
"""Fix cables by ensuring proper terminations.
221+
222+
This function processes all cables from the source adapter and validates/fixes
223+
termination information for both sides of each cable.
224+
225+
Args:
226+
adapter (SourceAdapter): The source adapter containing cable data
227+
"""
228+
adapter.logger.info("Creating missing cable terminations ...")
229+
wrapper = adapter.get_or_create_wrapper("dcim.cable")
230+
231+
for cable in adapter.get_all(wrapper.nautobot.diffsync_class):
232+
if getattr(cable, "termination_a_id", None) and getattr(cable, "termination_b_id", None):
233+
continue
234+
235+
adapter.logger.debug(f"Processing missing cable terminations {getattr(cable, 'id')} ...")
236+
237+
_update_cable_termination(wrapper, cable, "a")
238+
_update_cable_termination(wrapper, cable, "b")
239+
240+
241+
def setup(adapter: SourceAdapter) -> None:
242+
"""Map NetBox Cable related models to Nautobot."""
243+
adapter.disable_model("dcim.cablepath", "Recreated in Nautobot on signal when circuit termination is created")
244+
245+
adapter.configure_model(
246+
"dcim.cable",
247+
fields={
248+
"label": _define_cable_label,
249+
"termination_a_type": "",
250+
"termination_a_id": "",
251+
"termination_b_type": "",
252+
"termination_b_id": "",
253+
},
254+
)
255+
256+
adapter.configure_model(
257+
"dcim.cabletermination",
258+
extend_content_type="dcim.cable",
259+
pre_import_record=_pre_import_cable_termination,
260+
)
Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,46 @@
11
"""NetBox to Nautobot Circuits Models Mapping."""
22

3-
from nautobot_netbox_importer.generator import SourceAdapter
3+
from nautobot_netbox_importer.base import DUMMY_UID
4+
from nautobot_netbox_importer.generator import SourceAdapter, fields
45

56
from .locations import define_location
67

78

89
def setup(adapter: SourceAdapter) -> None:
910
"""Map NetBox circuits models to Nautobot."""
10-
adapter.configure_model(
11+
circuit_type = adapter.configure_model("circuits.circuittype")
12+
circuit_type.cache_record(
13+
{
14+
"id": DUMMY_UID,
15+
}
16+
)
17+
circuit_provider = adapter.configure_model("circuits.provider")
18+
circuit_provider.cache_record(
19+
{
20+
"id": DUMMY_UID,
21+
}
22+
)
23+
circuit = adapter.configure_model(
1124
"circuits.circuit",
1225
fields={
26+
"provider": "",
1327
"cid": fields.auto_increment(),
1428
"type": "circuit_type",
1529
"termination_a": "circuit_termination_a",
1630
"termination_z": "circuit_termination_z",
1731
},
32+
fill_dummy_data=lambda record, _: record.update(
33+
{
34+
"provider": DUMMY_UID,
35+
"type": DUMMY_UID,
36+
}
37+
),
1838
)
1939
adapter.configure_model(
2040
"circuits.circuittermination",
2141
fields={
42+
"circuit": "",
2243
"location": define_location,
2344
},
45+
fill_dummy_data=lambda record, suffix: record.update({"circuit": circuit.cache_dummy_object(suffix)}),
2446
)

nautobot_netbox_importer/diffsync/models/dcim.py

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -45,18 +45,8 @@ def units_importer(source: RecordData, target: DiffSyncBaseModel) -> None:
4545
field.set_importer(units_importer)
4646

4747

48-
def _pre_import_cable_termination(source: RecordData, _) -> PreImportRecordResult:
49-
cable_end = source.pop("cable_end").lower()
50-
source["id"] = source.pop("cable")
51-
source[f"termination_{cable_end}_type"] = source.pop("termination_type")
52-
source[f"termination_{cable_end}_id"] = source.pop("termination_id")
53-
54-
return PreImportRecordResult.USE_RECORD
55-
56-
5748
def setup(adapter: SourceAdapter) -> None:
5849
"""Map NetBox DCIM models to Nautobot."""
59-
adapter.disable_model("dcim.cablepath", "Recreated in Nautobot on signal when circuit termination is created")
6050
adapter.configure_model(
6151
"dcim.rackreservation",
6252
fields={
@@ -71,12 +61,6 @@ def setup(adapter: SourceAdapter) -> None:
7161
"role": fields.role(adapter, "dcim.rackrole"),
7262
},
7363
)
74-
adapter.configure_model("dcim.cable")
75-
adapter.configure_model(
76-
"dcim.cabletermination",
77-
extend_content_type="dcim.cable",
78-
pre_import=_pre_import_cable_termination,
79-
)
8064
adapter.configure_model(
8165
"dcim.interface",
8266
fields={

nautobot_netbox_importer/management/commands/import_netbox.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ def add_arguments(self, parser):
6363
dest="fix_powerfeed_locations",
6464
help="Fix panel location to match rack location based on powerfeed.",
6565
)
66+
parser.add_argument(
67+
"--create-missing-cable-terminations",
68+
action="store_true",
69+
dest="create_missing_cable_terminations",
70+
help="Create missing cable terminations as Nautobot requires both cable terminations to be defined to save cable instances.",
71+
)
6672
parser.add_argument(
6773
"--print-summary",
6874
action="store_true",

0 commit comments

Comments
 (0)