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.
@@ -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)