netbox-device-type-sync 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netbox_device_type_sync/__init__.py +23 -0
- netbox_device_type_sync/apply.py +402 -0
- netbox_device_type_sync/components.py +641 -0
- netbox_device_type_sync/diff.py +248 -0
- netbox_device_type_sync/forms.py +35 -0
- netbox_device_type_sync/templates/netbox_device_type_sync/device_sync.html +239 -0
- netbox_device_type_sync/tests/__init__.py +0 -0
- netbox_device_type_sync/tests/test_apply.py +427 -0
- netbox_device_type_sync/tests/test_coverage.py +174 -0
- netbox_device_type_sync/tests/test_diff.py +313 -0
- netbox_device_type_sync/tests/test_load.py +15 -0
- netbox_device_type_sync/tests/test_ui.py +111 -0
- netbox_device_type_sync/tests/test_views.py +123 -0
- netbox_device_type_sync/tests/utils.py +41 -0
- netbox_device_type_sync/ui.py +96 -0
- netbox_device_type_sync/urls.py +7 -0
- netbox_device_type_sync/version.py +1 -0
- netbox_device_type_sync/views.py +109 -0
- netbox_device_type_sync-1.0.0.dist-info/METADATA +267 -0
- netbox_device_type_sync-1.0.0.dist-info/RECORD +23 -0
- netbox_device_type_sync-1.0.0.dist-info/WHEEL +5 -0
- netbox_device_type_sync-1.0.0.dist-info/licenses/LICENSE +21 -0
- netbox_device_type_sync-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from netbox.plugins import PluginConfig
|
|
2
|
+
|
|
3
|
+
from .version import __version__
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NetBoxDeviceTypeSyncConfig(PluginConfig):
|
|
7
|
+
name = 'netbox_device_type_sync'
|
|
8
|
+
verbose_name = 'NetBox Device Type Sync'
|
|
9
|
+
version = __version__
|
|
10
|
+
description = 'Sync the components of a NetBox device with its device type.'
|
|
11
|
+
author = 'Maksym Ototiuk'
|
|
12
|
+
author_email = 'pypi@masik.slmail.me'
|
|
13
|
+
base_url = 'device-type-sync'
|
|
14
|
+
min_version = '4.7.4'
|
|
15
|
+
|
|
16
|
+
def ready(self):
|
|
17
|
+
super().ready()
|
|
18
|
+
from .ui import add_sync_button
|
|
19
|
+
|
|
20
|
+
add_sync_button()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
config = NetBoxDeviceTypeSyncConfig
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Apply a sync plan to a device.
|
|
3
|
+
|
|
4
|
+
apply_plan() creates the missing components and copies the selected
|
|
5
|
+
properties from the templates to the device components, in one database
|
|
6
|
+
transaction. If any object fails validation or the user lacks a permission,
|
|
7
|
+
it raises SyncError with every error found, and nothing is changed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
|
15
|
+
from django.db import IntegrityError, router, transaction
|
|
16
|
+
from django.utils.text import capfirst
|
|
17
|
+
from django.utils.translation import gettext as _
|
|
18
|
+
|
|
19
|
+
from dcim.models import Device, PortMapping, RearPort
|
|
20
|
+
from extras.models import CustomField
|
|
21
|
+
|
|
22
|
+
from .components import (
|
|
23
|
+
PortMappingsProperty,
|
|
24
|
+
RelationProperty,
|
|
25
|
+
SyncContext,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = (
|
|
29
|
+
'SyncError',
|
|
30
|
+
'SyncResult',
|
|
31
|
+
'apply_plan',
|
|
32
|
+
'describe_kind_counts',
|
|
33
|
+
'format_validation_error',
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger('netbox.plugins.netbox_device_type_sync')
|
|
37
|
+
|
|
38
|
+
# Kinds whose templates refer to templates of the same kind; parents first
|
|
39
|
+
_SELF_REFERENCING = {'interface', 'inventoryitem'}
|
|
40
|
+
|
|
41
|
+
# Relations that a template's instantiate() resolves itself
|
|
42
|
+
_INSTANTIATE_LOOKUPS = {
|
|
43
|
+
'poweroutlet': 'power_port',
|
|
44
|
+
'coolingoutflow': 'cooling_intake',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SyncError(Exception):
|
|
49
|
+
def __init__(self, errors):
|
|
50
|
+
super().__init__('; '.join(errors))
|
|
51
|
+
self.errors = errors
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class SyncResult:
|
|
56
|
+
device_fields: list = field(default_factory=list)
|
|
57
|
+
created: Counter = field(default_factory=Counter)
|
|
58
|
+
updated: Counter = field(default_factory=Counter)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def created_count(self):
|
|
62
|
+
return sum(self.created.values())
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def updated_count(self):
|
|
66
|
+
return sum(self.updated.values())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def format_validation_error(error):
|
|
70
|
+
if hasattr(error, 'error_dict'):
|
|
71
|
+
parts = []
|
|
72
|
+
for field_name, messages in error.message_dict.items():
|
|
73
|
+
text = ' '.join(messages)
|
|
74
|
+
parts.append(text if field_name == '__all__' else f'{field_name}: {text}')
|
|
75
|
+
return ' '.join(parts)
|
|
76
|
+
return ' '.join(error.messages)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _template_depth(kind, template):
|
|
80
|
+
if kind.key not in _SELF_REFERENCING:
|
|
81
|
+
return 0
|
|
82
|
+
depth = 0
|
|
83
|
+
while template.parent is not None:
|
|
84
|
+
depth += 1
|
|
85
|
+
template = template.parent
|
|
86
|
+
return depth
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _Applier:
|
|
90
|
+
def __init__(self, plan, selected, user, changelog_message):
|
|
91
|
+
self.plan = plan
|
|
92
|
+
self.device = plan.device
|
|
93
|
+
self.selected = set(selected) & set(plan.selection_keys)
|
|
94
|
+
self.user = user
|
|
95
|
+
self.changelog_message = changelog_message
|
|
96
|
+
self.ctx = SyncContext(self.device)
|
|
97
|
+
self.errors = []
|
|
98
|
+
self.result = SyncResult()
|
|
99
|
+
# Interfaces whose bridge is set after all interfaces exist
|
|
100
|
+
self.pending_bridges = []
|
|
101
|
+
# Front ports whose mappings are created after all ports exist
|
|
102
|
+
self.pending_mappings = []
|
|
103
|
+
|
|
104
|
+
def is_selected(self, kind, prop):
|
|
105
|
+
return f'{kind.key}.{prop.key}' in self.selected
|
|
106
|
+
|
|
107
|
+
def error(self, kind, name, message):
|
|
108
|
+
self.errors.append(f'{kind.verbose_name} {name}: {message}')
|
|
109
|
+
|
|
110
|
+
#
|
|
111
|
+
# Saving
|
|
112
|
+
#
|
|
113
|
+
|
|
114
|
+
def save(self, obj, action, describe):
|
|
115
|
+
"""
|
|
116
|
+
Validate, save and check object permissions. Return True on success.
|
|
117
|
+
"""
|
|
118
|
+
obj._changelog_message = self.changelog_message
|
|
119
|
+
try:
|
|
120
|
+
obj.full_clean()
|
|
121
|
+
# A savepoint keeps the transaction usable after a database error
|
|
122
|
+
with transaction.atomic(using=router.db_for_write(type(obj))):
|
|
123
|
+
obj.save()
|
|
124
|
+
except ValidationError as e:
|
|
125
|
+
self.errors.append(f'{describe}: {format_validation_error(e)}')
|
|
126
|
+
return False
|
|
127
|
+
except IntegrityError as e:
|
|
128
|
+
self.errors.append(f'{describe}: {e}')
|
|
129
|
+
return False
|
|
130
|
+
if self.user is not None and action is not None:
|
|
131
|
+
permitted = type(obj).objects.restrict(self.user, action).filter(pk=obj.pk)
|
|
132
|
+
if not permitted.exists():
|
|
133
|
+
self.errors.append(
|
|
134
|
+
_('{object}: you do not have permission to {action} it.').format(
|
|
135
|
+
object=describe, action=action
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
return False
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
#
|
|
142
|
+
# Device fields
|
|
143
|
+
#
|
|
144
|
+
|
|
145
|
+
def apply_device_fields(self):
|
|
146
|
+
diffs = [f for f in self.plan.device_fields if f.selection_key in self.selected]
|
|
147
|
+
if not diffs:
|
|
148
|
+
return
|
|
149
|
+
device = self.device
|
|
150
|
+
device.snapshot()
|
|
151
|
+
for diff in diffs:
|
|
152
|
+
source = self.plan.device_type._meta.get_field(diff.field.source)
|
|
153
|
+
target = device._meta.get_field(diff.field.name)
|
|
154
|
+
setattr(
|
|
155
|
+
device, target.attname, getattr(self.plan.device_type, source.attname)
|
|
156
|
+
)
|
|
157
|
+
if self.save(device, 'change', str(device)):
|
|
158
|
+
self.result.device_fields = [d.label for d in diffs]
|
|
159
|
+
|
|
160
|
+
#
|
|
161
|
+
# Components
|
|
162
|
+
#
|
|
163
|
+
|
|
164
|
+
def apply_kind(self, kind_diff):
|
|
165
|
+
kind = kind_diff.kind
|
|
166
|
+
items = [(d.template, d) for d in kind_diff.update]
|
|
167
|
+
items += [(c.template, c) for c in kind_diff.create]
|
|
168
|
+
items.sort(key=lambda item: _template_depth(kind, item[0]))
|
|
169
|
+
for template, item in items:
|
|
170
|
+
if hasattr(item, 'component'):
|
|
171
|
+
self.update_component(kind, item)
|
|
172
|
+
else:
|
|
173
|
+
self.create_component(kind, item)
|
|
174
|
+
if kind.key == 'inventoryitem':
|
|
175
|
+
self.ctx.forget_inventory_items()
|
|
176
|
+
|
|
177
|
+
def split_properties(self, kind, diff):
|
|
178
|
+
"""
|
|
179
|
+
Return the selected properties of a component diff that are written
|
|
180
|
+
before and after the component is saved. Port mappings and interface
|
|
181
|
+
bridges are queued for the passes that run once all components exist.
|
|
182
|
+
"""
|
|
183
|
+
direct, deferred = [], []
|
|
184
|
+
for key in diff.changes:
|
|
185
|
+
prop = kind.get_property(key)
|
|
186
|
+
if not self.is_selected(kind, prop):
|
|
187
|
+
continue
|
|
188
|
+
if isinstance(prop, PortMappingsProperty):
|
|
189
|
+
self.pending_mappings.append((diff.component, diff.template, False))
|
|
190
|
+
elif kind.key == 'interface' and prop.key == 'bridge':
|
|
191
|
+
self.pending_bridges.append((diff.component, diff.template, diff.name))
|
|
192
|
+
elif prop.deferred:
|
|
193
|
+
deferred.append(prop)
|
|
194
|
+
else:
|
|
195
|
+
direct.append(prop)
|
|
196
|
+
return direct, deferred
|
|
197
|
+
|
|
198
|
+
def update_component(self, kind, diff):
|
|
199
|
+
selected = [
|
|
200
|
+
k for k in diff.changes if self.is_selected(kind, kind.get_property(k))
|
|
201
|
+
]
|
|
202
|
+
if not selected:
|
|
203
|
+
return
|
|
204
|
+
component = diff.component
|
|
205
|
+
component.snapshot()
|
|
206
|
+
direct, deferred = self.split_properties(kind, diff)
|
|
207
|
+
self.result.updated[kind.key] += 1
|
|
208
|
+
if not (direct or deferred):
|
|
209
|
+
return
|
|
210
|
+
for prop in direct:
|
|
211
|
+
if message := prop.write(component, diff.template, self.ctx):
|
|
212
|
+
self.error(kind, diff.name, message)
|
|
213
|
+
return
|
|
214
|
+
if self.save(component, 'change', f'{kind.verbose_name} {diff.name}'):
|
|
215
|
+
for prop in deferred:
|
|
216
|
+
prop.write(component, diff.template, self.ctx)
|
|
217
|
+
|
|
218
|
+
def instantiate(self, kind, template):
|
|
219
|
+
"""
|
|
220
|
+
Build a new component from its template the way NetBox does for a new
|
|
221
|
+
device (dcim.models.Device._instantiate_components()).
|
|
222
|
+
"""
|
|
223
|
+
if kind.key == 'inventoryitem':
|
|
224
|
+
# InventoryItemTemplate.instantiate() looks up the parent by name
|
|
225
|
+
# only; build the item here so the parent is matched by its path.
|
|
226
|
+
component = kind.component_model(
|
|
227
|
+
device=self.device,
|
|
228
|
+
name=template.name,
|
|
229
|
+
label=template.label,
|
|
230
|
+
role=template.role,
|
|
231
|
+
manufacturer=template.manufacturer,
|
|
232
|
+
part_id=template.part_id,
|
|
233
|
+
)
|
|
234
|
+
for prop in (kind.get_property('parent'), kind.get_property('component')):
|
|
235
|
+
if message := prop.write(component, template, self.ctx):
|
|
236
|
+
raise ObjectDoesNotExist(message)
|
|
237
|
+
return component
|
|
238
|
+
if relation := _INSTANTIATE_LOOKUPS.get(kind.key):
|
|
239
|
+
# instantiate() looks the related component up by name; check it
|
|
240
|
+
# first to report a readable error
|
|
241
|
+
name, _display = RelationProperty(relation).read_template(
|
|
242
|
+
template, self.ctx
|
|
243
|
+
)
|
|
244
|
+
model = kind.component_model._meta.get_field(relation).related_model
|
|
245
|
+
if name is not None and self.ctx.find_component(model, name) is None:
|
|
246
|
+
raise ObjectDoesNotExist(
|
|
247
|
+
_('{model} {name} does not exist on the device.').format(
|
|
248
|
+
model=capfirst(model._meta.verbose_name), name=name
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
component = template.instantiate(device=self.device)
|
|
252
|
+
if kind.key == 'interface' and template.parent is not None:
|
|
253
|
+
# NetBox sets interface parents after instantiation (update_interface_parents())
|
|
254
|
+
message = RelationProperty('parent').write(component, template, self.ctx)
|
|
255
|
+
if message:
|
|
256
|
+
raise ObjectDoesNotExist(message)
|
|
257
|
+
return component
|
|
258
|
+
|
|
259
|
+
def create_component(self, kind, item):
|
|
260
|
+
try:
|
|
261
|
+
component = self.instantiate(kind, item.template)
|
|
262
|
+
except ObjectDoesNotExist as e:
|
|
263
|
+
self.error(
|
|
264
|
+
kind, item.name, str(e) or _('A related component does not exist.')
|
|
265
|
+
)
|
|
266
|
+
return
|
|
267
|
+
if cf_defaults := CustomField.objects.get_defaults_for_model(
|
|
268
|
+
kind.component_model
|
|
269
|
+
):
|
|
270
|
+
component.custom_field_data = cf_defaults
|
|
271
|
+
if not self.save(component, 'add', f'{kind.verbose_name} {item.name}'):
|
|
272
|
+
return
|
|
273
|
+
if src := getattr(component, '_source_template', None):
|
|
274
|
+
# Module bays: copy module_bay_types, as Device._instantiate_components() does
|
|
275
|
+
component.module_bay_types.set(src.module_bay_types.all())
|
|
276
|
+
if kind.key == 'interface' and item.template.bridge is not None:
|
|
277
|
+
self.pending_bridges.append((component, item.template, item.name))
|
|
278
|
+
if kind.key == 'frontport':
|
|
279
|
+
self.pending_mappings.append((component, item.template, True))
|
|
280
|
+
self.result.created[kind.key] += 1
|
|
281
|
+
|
|
282
|
+
def apply_bridges(self, kind):
|
|
283
|
+
bridge = kind.get_property('bridge')
|
|
284
|
+
for component, template, name in self.pending_bridges:
|
|
285
|
+
component.snapshot()
|
|
286
|
+
if message := bridge.write(component, template, self.ctx):
|
|
287
|
+
self.error(kind, name, message)
|
|
288
|
+
continue
|
|
289
|
+
self.save(component, 'change', f'{kind.verbose_name} {name}')
|
|
290
|
+
|
|
291
|
+
#
|
|
292
|
+
# Port mappings
|
|
293
|
+
#
|
|
294
|
+
|
|
295
|
+
def remove_obsolete_mappings(self, kind_diff):
|
|
296
|
+
# Before port positions change, so that fewer positions validate
|
|
297
|
+
prop = kind_diff.kind.get_property('mappings')
|
|
298
|
+
if not self.is_selected(kind_diff.kind, prop):
|
|
299
|
+
return
|
|
300
|
+
for diff in kind_diff.update:
|
|
301
|
+
if 'mappings' not in diff.changes:
|
|
302
|
+
continue
|
|
303
|
+
desired = prop.desired(diff.template, self.ctx)
|
|
304
|
+
for mapping in diff.component.mappings.select_related('rear_port'):
|
|
305
|
+
key = (
|
|
306
|
+
mapping.front_port_position,
|
|
307
|
+
mapping.rear_port.name,
|
|
308
|
+
mapping.rear_port_position,
|
|
309
|
+
)
|
|
310
|
+
if key not in desired:
|
|
311
|
+
mapping._changelog_message = self.changelog_message
|
|
312
|
+
mapping.delete()
|
|
313
|
+
|
|
314
|
+
def create_mappings(self, kind):
|
|
315
|
+
prop = kind.get_property('mappings')
|
|
316
|
+
for front_port, template, is_new in self.pending_mappings:
|
|
317
|
+
existing = set() if is_new else prop.current(front_port)
|
|
318
|
+
for front_pos, rear_name, rear_pos in sorted(
|
|
319
|
+
prop.desired(template, self.ctx)
|
|
320
|
+
):
|
|
321
|
+
if (front_pos, rear_name, rear_pos) in existing:
|
|
322
|
+
continue
|
|
323
|
+
rear_port = self.ctx.find_component(RearPort, rear_name)
|
|
324
|
+
if rear_port is None:
|
|
325
|
+
self.error(
|
|
326
|
+
kind,
|
|
327
|
+
front_port.name,
|
|
328
|
+
_('Rear port {name} does not exist on the device.').format(
|
|
329
|
+
name=rear_name
|
|
330
|
+
),
|
|
331
|
+
)
|
|
332
|
+
continue
|
|
333
|
+
mapping = PortMapping(
|
|
334
|
+
device=self.device,
|
|
335
|
+
front_port=front_port,
|
|
336
|
+
front_port_position=front_pos,
|
|
337
|
+
rear_port=rear_port,
|
|
338
|
+
rear_port_position=rear_pos,
|
|
339
|
+
)
|
|
340
|
+
self.save(mapping, None, f'{kind.verbose_name} {front_port.name}')
|
|
341
|
+
|
|
342
|
+
#
|
|
343
|
+
# Entry point
|
|
344
|
+
#
|
|
345
|
+
|
|
346
|
+
def run(self):
|
|
347
|
+
self.apply_device_fields()
|
|
348
|
+
kind_diffs = {k.kind.key: k for k in self.plan.kinds}
|
|
349
|
+
self.remove_obsolete_mappings(kind_diffs['frontport'])
|
|
350
|
+
for kind_diff in self.plan.kinds:
|
|
351
|
+
self.apply_kind(kind_diff)
|
|
352
|
+
if kind_diff.kind.key == 'interface':
|
|
353
|
+
self.apply_bridges(kind_diff.kind)
|
|
354
|
+
elif kind_diff.kind.key == 'frontport':
|
|
355
|
+
self.create_mappings(kind_diff.kind)
|
|
356
|
+
return self.result
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def apply_plan(plan, selected, user=None, changelog_message=''):
|
|
360
|
+
"""
|
|
361
|
+
Apply the plan to its device.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
plan: A SyncPlan from diff.build_plan()
|
|
365
|
+
selected: The selection keys of the properties to copy from the device
|
|
366
|
+
type (for example "interface.type" or "device.airflow"). Missing
|
|
367
|
+
components are always created.
|
|
368
|
+
user: The user whose object permissions are enforced (None: no checks)
|
|
369
|
+
changelog_message: The message recorded with every change
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
A SyncResult. Raises SyncError (and changes nothing) on any error.
|
|
373
|
+
"""
|
|
374
|
+
applier = _Applier(plan, selected, user, changelog_message)
|
|
375
|
+
with transaction.atomic(using=router.db_for_write(Device)):
|
|
376
|
+
result = applier.run()
|
|
377
|
+
if applier.errors:
|
|
378
|
+
raise SyncError(applier.errors)
|
|
379
|
+
logger.info(
|
|
380
|
+
'Synced device %s with device type %s: %d created, %d updated',
|
|
381
|
+
plan.device.pk,
|
|
382
|
+
plan.device_type.pk,
|
|
383
|
+
result.created_count,
|
|
384
|
+
result.updated_count,
|
|
385
|
+
)
|
|
386
|
+
return result
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def describe_kind_counts(counter):
|
|
390
|
+
"""
|
|
391
|
+
Return e.g. "3 interfaces, 1 console port" for a Counter of kind keys.
|
|
392
|
+
"""
|
|
393
|
+
from .components import get_kind
|
|
394
|
+
|
|
395
|
+
parts = []
|
|
396
|
+
for key, count in counter.items():
|
|
397
|
+
model = get_kind(key).component_model
|
|
398
|
+
name = (
|
|
399
|
+
model._meta.verbose_name if count == 1 else model._meta.verbose_name_plural
|
|
400
|
+
)
|
|
401
|
+
parts.append(f'{count} {name}')
|
|
402
|
+
return ', '.join(parts)
|