netbox-vcenter-server 0.2.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,748 @@
1
+ """Views for NetBox vCenter plugin."""
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+
7
+ from django.conf import settings
8
+ from django.contrib import messages
9
+ from django.core.cache import cache
10
+ from django.shortcuts import redirect, render
11
+ from django.utils import timezone
12
+ from django.views.generic import View
13
+ from dcim.models import Platform
14
+ from extras.models import Tag
15
+ from ipam.models import IPAddress
16
+ from virtualization.models import Cluster, VMInterface, VirtualMachine
17
+
18
+ from .client import connect_and_fetch
19
+ from .forms import VCenterConnectForm, VMImportForm
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def normalize_name(name: str, mode: str = "exact", pattern: str = None) -> str:
25
+ """
26
+ Normalize a VM name for matching based on the configured mode.
27
+
28
+ Args:
29
+ name: The VM name to normalize
30
+ mode: Match mode - "exact", "hostname", or "regex"
31
+ pattern: Regex pattern (used when mode is "regex")
32
+
33
+ Returns:
34
+ Normalized name for comparison (always lowercase)
35
+ """
36
+ if not name:
37
+ return ""
38
+
39
+ name = name.strip()
40
+
41
+ if mode == "hostname":
42
+ # Strip domain - everything after first dot
43
+ name = name.split(".")[0]
44
+ elif mode == "regex" and pattern:
45
+ try:
46
+ match = re.match(pattern, name, re.IGNORECASE)
47
+ if match and match.groups():
48
+ name = match.group(1)
49
+ except re.error:
50
+ logger.warning(f"Invalid regex pattern: {pattern}")
51
+
52
+ return name.lower()
53
+
54
+
55
+ def get_name_match_config() -> tuple[str, str]:
56
+ """Get the name matching configuration."""
57
+ config = settings.PLUGINS_CONFIG.get("netbox_vcenter", {})
58
+ mode = config.get("name_match_mode", "exact")
59
+ pattern = config.get("name_match_pattern", r"^([^.]+)")
60
+ return mode, pattern
61
+
62
+
63
+ def get_import_config() -> dict:
64
+ """Get the import/sync configuration settings."""
65
+ config = settings.PLUGINS_CONFIG.get("netbox_vcenter", {})
66
+ return {
67
+ "normalize_name": config.get("normalize_imported_name", True),
68
+ "default_tag": config.get("default_tag", ""),
69
+ "default_role": config.get("default_role", ""),
70
+ "default_platform": config.get("default_platform", ""),
71
+ }
72
+
73
+
74
+ def get_name_for_import(vm_name: str, normalize: bool, mode: str, pattern: str) -> str:
75
+ """
76
+ Get the name to use when importing a VM.
77
+
78
+ Args:
79
+ vm_name: Original vCenter VM name
80
+ normalize: Whether to normalize (strip domain, lowercase)
81
+ mode: Name matching mode for normalization
82
+ pattern: Regex pattern for regex mode
83
+
84
+ Returns:
85
+ Name to use in NetBox
86
+ """
87
+ if normalize:
88
+ return normalize_name(vm_name, mode, pattern)
89
+ return vm_name
90
+
91
+
92
+ def build_netbox_name_map(mode: str, pattern: str) -> dict[str, str]:
93
+ """
94
+ Build a mapping of normalized names to original NetBox VM names.
95
+
96
+ Returns:
97
+ Dict mapping normalized_name -> original_name
98
+ """
99
+ name_map = {}
100
+ for name in VirtualMachine.objects.values_list("name", flat=True):
101
+ normalized = normalize_name(name, mode, pattern)
102
+ if normalized not in name_map:
103
+ name_map[normalized] = name
104
+ return name_map
105
+
106
+
107
+ def check_vm_exists(vm_name: str, mode: str, pattern: str, netbox_names: set) -> bool:
108
+ """Check if a VM exists in NetBox using the configured matching mode."""
109
+ normalized = normalize_name(vm_name, mode, pattern)
110
+ return normalized in netbox_names
111
+
112
+
113
+ def get_cache_key(server: str) -> str:
114
+ """Generate cache key for a vCenter server."""
115
+ return f"vcenter_vms_{server.replace('.', '_')}"
116
+
117
+
118
+ def get_cached_data(server: str) -> dict:
119
+ """Get cached VM data for a vCenter server."""
120
+ return cache.get(get_cache_key(server))
121
+
122
+
123
+ def get_all_cached_data() -> dict:
124
+ """Get cached data for all configured vCenter servers."""
125
+ config = settings.PLUGINS_CONFIG.get("netbox_vcenter", {})
126
+ servers = config.get("vcenter_servers", [])
127
+
128
+ cached_data = {}
129
+ for server in servers:
130
+ data = get_cached_data(server)
131
+ cached_data[server] = data
132
+
133
+ return cached_data
134
+
135
+
136
+ class VCenterDashboardView(View):
137
+ """Main dashboard for viewing and syncing vCenter VMs."""
138
+
139
+ template_name = "netbox_vcenter/dashboard.html"
140
+
141
+ def get(self, request):
142
+ """Display the dashboard with connection form and cached VMs."""
143
+ form = VCenterConnectForm()
144
+ cached_data = get_all_cached_data()
145
+
146
+ # Get the selected server tab (default to first server with data, or first server)
147
+ config = settings.PLUGINS_CONFIG.get("netbox_vcenter", {})
148
+ servers = config.get("vcenter_servers", [])
149
+ selected_server = request.GET.get("server")
150
+
151
+ if not selected_server:
152
+ # Default to "all" if multiple servers have data, otherwise first server with data
153
+ servers_with_data = [s for s in servers if cached_data.get(s)]
154
+ if len(servers_with_data) > 1:
155
+ selected_server = "all"
156
+ elif servers_with_data:
157
+ selected_server = servers_with_data[0]
158
+ elif servers:
159
+ selected_server = servers[0]
160
+
161
+ # Get VMs for selected server (or all servers)
162
+ if selected_server == "all":
163
+ # Combine VMs from all servers
164
+ vms = []
165
+ for server in servers:
166
+ server_data = cached_data.get(server)
167
+ if server_data:
168
+ for vm in server_data.get("vms", []):
169
+ vm_copy = vm.copy()
170
+ vm_copy["source_server"] = server
171
+ vms.append(vm_copy)
172
+ else:
173
+ selected_data = cached_data.get(selected_server) if selected_server else None
174
+ vms = selected_data.get("vms", []) if selected_data else []
175
+ for vm in vms:
176
+ vm["source_server"] = selected_server
177
+
178
+ # Sort VMs by name
179
+ vms = sorted(vms, key=lambda x: x.get("name", "").lower())
180
+
181
+ # Get name matching config and check which VMs already exist in NetBox
182
+ match_mode, match_pattern = get_name_match_config()
183
+ existing_normalized = {
184
+ normalize_name(name, match_mode, match_pattern)
185
+ for name in VirtualMachine.objects.values_list("name", flat=True)
186
+ }
187
+ for vm in vms:
188
+ vm_normalized = normalize_name(vm.get("name", ""), match_mode, match_pattern)
189
+ vm["exists_in_netbox"] = vm_normalized in existing_normalized
190
+
191
+ # Get MFA settings
192
+ mfa_enabled = config.get("mfa_enabled", True)
193
+ mfa_label = config.get("mfa_label", "2FA")
194
+ mfa_message = config.get("mfa_message", "Check your device for an authentication prompt.")
195
+
196
+ return render(
197
+ request,
198
+ self.template_name,
199
+ {
200
+ "form": form,
201
+ "servers": servers,
202
+ "cached_data": cached_data,
203
+ "selected_server": selected_server,
204
+ "vms": vms,
205
+ "vm_count": len(vms),
206
+ "mfa_enabled": mfa_enabled,
207
+ "mfa_label": mfa_label,
208
+ "mfa_message": mfa_message,
209
+ },
210
+ )
211
+
212
+ def post(self, request):
213
+ """Connect to vCenter and fetch VMs."""
214
+ form = VCenterConnectForm(request.POST)
215
+
216
+ if form.is_valid():
217
+ server = form.cleaned_data["server"]
218
+ username = form.cleaned_data["username"]
219
+ password = form.cleaned_data["password"]
220
+ verify_ssl = form.cleaned_data.get("verify_ssl", False)
221
+
222
+ # Connect and fetch VMs
223
+ vms, error = connect_and_fetch(server, username, password, verify_ssl)
224
+
225
+ if error:
226
+ messages.error(request, error)
227
+ else:
228
+ # Cache the data (no timeout - persists until manual refresh)
229
+ cache_data = {
230
+ "vms": vms,
231
+ "timestamp": timezone.now().isoformat(),
232
+ "server": server,
233
+ "count": len(vms),
234
+ }
235
+ cache.set(get_cache_key(server), cache_data, None)
236
+ messages.success(request, f"Successfully synced {len(vms)} VMs from {server}")
237
+
238
+ return redirect(f"{request.path}?server={server}")
239
+
240
+ # Form invalid
241
+ cached_data = get_all_cached_data()
242
+ config = settings.PLUGINS_CONFIG.get("netbox_vcenter", {})
243
+ servers = config.get("vcenter_servers", [])
244
+
245
+ # Get MFA settings
246
+ mfa_enabled = config.get("mfa_enabled", True)
247
+ mfa_label = config.get("mfa_label", "2FA")
248
+ mfa_message = config.get("mfa_message", "Check your device for an authentication prompt.")
249
+
250
+ return render(
251
+ request,
252
+ self.template_name,
253
+ {
254
+ "form": form,
255
+ "servers": servers,
256
+ "cached_data": cached_data,
257
+ "selected_server": servers[0] if servers else None,
258
+ "vms": [],
259
+ "vm_count": 0,
260
+ "mfa_enabled": mfa_enabled,
261
+ "mfa_label": mfa_label,
262
+ "mfa_message": mfa_message,
263
+ },
264
+ )
265
+
266
+
267
+ class VCenterRefreshView(View):
268
+ """Clear cached data for a vCenter server."""
269
+
270
+ def get(self, request, server):
271
+ """Clear cache and redirect to dashboard."""
272
+ cache.delete(get_cache_key(server))
273
+ messages.info(request, f"Cache cleared for {server}. Enter credentials to sync again.")
274
+ return redirect(f"/plugins/vcenter/?server={server}")
275
+
276
+
277
+ class VMImportView(View):
278
+ """Import selected VMs from vCenter to NetBox."""
279
+
280
+ template_name = "netbox_vcenter/import.html"
281
+
282
+ def get(self, request):
283
+ """Show import preview/confirmation page."""
284
+ selected_vms_json = request.GET.get("vms", "[]")
285
+ server = request.GET.get("server", "")
286
+
287
+ try:
288
+ selected_vm_names = json.loads(selected_vms_json)
289
+ except json.JSONDecodeError:
290
+ selected_vm_names = []
291
+
292
+ if not selected_vm_names:
293
+ messages.warning(request, "No VMs selected for import")
294
+ return redirect("plugins:netbox_vcenter:dashboard")
295
+
296
+ # Get VM details from cache
297
+ cached_data = get_cached_data(server)
298
+ if not cached_data:
299
+ messages.error(request, f"No cached data for {server}. Please sync first.")
300
+ return redirect("plugins:netbox_vcenter:dashboard")
301
+
302
+ # Filter to selected VMs
303
+ all_vms = cached_data.get("vms", [])
304
+ vms_to_import = [vm for vm in all_vms if vm.get("name") in selected_vm_names]
305
+
306
+ # Get name matching config and check which VMs already exist in NetBox
307
+ match_mode, match_pattern = get_name_match_config()
308
+ existing_normalized = {
309
+ normalize_name(name, match_mode, match_pattern)
310
+ for name in VirtualMachine.objects.values_list("name", flat=True)
311
+ }
312
+ for vm in vms_to_import:
313
+ vm_normalized = normalize_name(vm.get("name", ""), match_mode, match_pattern)
314
+ vm["exists_in_netbox"] = vm_normalized in existing_normalized
315
+
316
+ form = VMImportForm(
317
+ initial={
318
+ "selected_vms": json.dumps(selected_vm_names),
319
+ "vcenter_server": server,
320
+ }
321
+ )
322
+
323
+ return render(
324
+ request,
325
+ self.template_name,
326
+ {
327
+ "form": form,
328
+ "vms": vms_to_import,
329
+ "server": server,
330
+ "new_count": len([v for v in vms_to_import if not v.get("exists_in_netbox")]),
331
+ "existing_count": len([v for v in vms_to_import if v.get("exists_in_netbox")]),
332
+ },
333
+ )
334
+
335
+ def post(self, request):
336
+ """Import the selected VMs to NetBox."""
337
+ form = VMImportForm(request.POST)
338
+
339
+ if not form.is_valid():
340
+ messages.error(request, "Invalid form data")
341
+ return redirect("plugins:netbox_vcenter:dashboard")
342
+
343
+ selected_vm_names = form.cleaned_data["selected_vms"]
344
+ server = form.cleaned_data["vcenter_server"]
345
+ cluster = form.cleaned_data["cluster"]
346
+ update_existing = form.cleaned_data.get("update_existing", False)
347
+
348
+ # Get VM details from cache
349
+ cached_data = get_cached_data(server)
350
+ if not cached_data:
351
+ messages.error(request, f"No cached data for {server}. Please sync first.")
352
+ return redirect("plugins:netbox_vcenter:dashboard")
353
+
354
+ # Filter to selected VMs
355
+ all_vms = cached_data.get("vms", [])
356
+ vms_to_import = [vm for vm in all_vms if vm.get("name") in selected_vm_names]
357
+
358
+ # Get name matching config for duplicate detection
359
+ match_mode, match_pattern = get_name_match_config()
360
+
361
+ # Get import configuration
362
+ import_config = get_import_config()
363
+ normalize_names = import_config["normalize_name"]
364
+ default_tag_slug = import_config["default_tag"]
365
+ default_role_slug = import_config["default_role"]
366
+ default_platform_slug = import_config["default_platform"]
367
+
368
+ # Look up default tag, role, platform if configured
369
+ default_tag = None
370
+ default_role = None
371
+ default_platform = None
372
+
373
+ if default_tag_slug:
374
+ try:
375
+ default_tag = Tag.objects.get(slug=default_tag_slug)
376
+ except Tag.DoesNotExist:
377
+ logger.warning(f"Default tag '{default_tag_slug}' not found in NetBox")
378
+
379
+ if default_role_slug:
380
+ try:
381
+ from dcim.models import DeviceRole
382
+ default_role = DeviceRole.objects.get(slug=default_role_slug)
383
+ except Exception:
384
+ logger.warning(f"Default role '{default_role_slug}' not found in NetBox")
385
+
386
+ if default_platform_slug:
387
+ try:
388
+ default_platform = Platform.objects.get(slug=default_platform_slug)
389
+ except Platform.DoesNotExist:
390
+ logger.warning(f"Default platform '{default_platform_slug}' not found in NetBox")
391
+
392
+ # Build map of normalized names to existing NetBox VMs
393
+ existing_vm_map = {}
394
+ for vm in VirtualMachine.objects.all():
395
+ normalized = normalize_name(vm.name, match_mode, match_pattern)
396
+ existing_vm_map[normalized] = vm
397
+
398
+ # Import/Update VMs
399
+ created = 0
400
+ updated = 0
401
+ skipped = 0
402
+ errors = []
403
+
404
+ for vm_data in vms_to_import:
405
+ vm_name = vm_data.get("name")
406
+ vm_normalized = normalize_name(vm_name, match_mode, match_pattern)
407
+
408
+ # Determine name to use for import
409
+ import_name = get_name_for_import(vm_name, normalize_names, match_mode, match_pattern)
410
+
411
+ # Check if VM already exists
412
+ existing_vm = existing_vm_map.get(vm_normalized)
413
+
414
+ if existing_vm:
415
+ if update_existing:
416
+ try:
417
+ # Update existing VM specs
418
+ existing_vm.vcpus = vm_data.get("vcpus")
419
+ existing_vm.memory = vm_data.get("memory_mb")
420
+ existing_vm.disk = vm_data.get("disk_gb")
421
+ existing_vm.status = "active" if vm_data.get("power_state") == "on" else "offline"
422
+
423
+ # Update role and platform if configured and not already set
424
+ if default_role and not existing_vm.role:
425
+ existing_vm.role = default_role
426
+ if default_platform and not existing_vm.platform:
427
+ existing_vm.platform = default_platform
428
+
429
+ # Update primary IP if available
430
+ primary_ip = vm_data.get("primary_ip")
431
+ if primary_ip:
432
+ self._update_vm_primary_ip(existing_vm, primary_ip)
433
+
434
+ existing_vm.full_clean()
435
+ existing_vm.save()
436
+
437
+ # Add tag if configured
438
+ if default_tag:
439
+ existing_vm.tags.add(default_tag)
440
+
441
+ updated += 1
442
+ except Exception as e:
443
+ errors.append(f"{vm_name}: {str(e)}")
444
+ logger.error(f"Error updating VM {vm_name}: {e}")
445
+ else:
446
+ skipped += 1
447
+ continue
448
+
449
+ try:
450
+ # Determine status based on power state
451
+ status = "active" if vm_data.get("power_state") == "on" else "offline"
452
+
453
+ # Create the VM with normalized or original name
454
+ vm = VirtualMachine(
455
+ name=import_name,
456
+ cluster=cluster,
457
+ vcpus=vm_data.get("vcpus"),
458
+ memory=vm_data.get("memory_mb"),
459
+ disk=vm_data.get("disk_gb"),
460
+ status=status,
461
+ role=default_role,
462
+ platform=default_platform,
463
+ comments=f"Imported from vCenter {server} on {timezone.now().strftime('%Y-%m-%d %H:%M')}",
464
+ )
465
+ vm.full_clean()
466
+ vm.save()
467
+
468
+ # Add tag if configured
469
+ if default_tag:
470
+ vm.tags.add(default_tag)
471
+
472
+ # Set primary IP if available
473
+ primary_ip = vm_data.get("primary_ip")
474
+ if primary_ip:
475
+ self._update_vm_primary_ip(vm, primary_ip)
476
+
477
+ created += 1
478
+
479
+ except Exception as e:
480
+ errors.append(f"{vm_name}: {str(e)}")
481
+ logger.error(f"Error importing VM {vm_name}: {e}")
482
+
483
+ # Build result message
484
+ if created:
485
+ messages.success(request, f"Created {created} VM(s) in cluster '{cluster.name}'")
486
+ if updated:
487
+ messages.success(request, f"Updated {updated} existing VM(s)")
488
+ if skipped:
489
+ messages.info(request, f"Skipped {skipped} VM(s) (already exist, update not selected)")
490
+ if errors:
491
+ messages.warning(request, f"Failed: {len(errors)} VM(s): {'; '.join(errors[:3])}")
492
+
493
+ return redirect("plugins:netbox_vcenter:dashboard")
494
+
495
+ def _update_vm_primary_ip(self, vm, ip_address):
496
+ """
497
+ Update or create the primary IP address for a VM.
498
+
499
+ Creates a VMInterface if needed, then creates/updates the IPAddress.
500
+ """
501
+ if not ip_address:
502
+ return
503
+
504
+ # Skip IPv6 link-local addresses
505
+ if ip_address.startswith("fe80:"):
506
+ return
507
+
508
+ # Get or create the default interface
509
+ interface, _ = VMInterface.objects.get_or_create(
510
+ virtual_machine=vm,
511
+ name="eth0",
512
+ defaults={"enabled": True},
513
+ )
514
+
515
+ # Determine if IPv4 or IPv6
516
+ is_ipv6 = ":" in ip_address
517
+
518
+ # Add CIDR notation if not present
519
+ if "/" not in ip_address:
520
+ ip_address = f"{ip_address}/32" if not is_ipv6 else f"{ip_address}/128"
521
+
522
+ # Get or create the IP address
523
+ ip_obj, created = IPAddress.objects.get_or_create(
524
+ address=ip_address,
525
+ defaults={"assigned_object": interface},
526
+ )
527
+
528
+ # If IP exists but not assigned to this interface, update it
529
+ if not created and ip_obj.assigned_object != interface:
530
+ ip_obj.assigned_object = interface
531
+ ip_obj.save()
532
+
533
+ # Set as primary IP on the VM
534
+ if is_ipv6:
535
+ if vm.primary_ip6 != ip_obj:
536
+ vm.primary_ip6 = ip_obj
537
+ vm.save()
538
+ else:
539
+ if vm.primary_ip4 != ip_obj:
540
+ vm.primary_ip4 = ip_obj
541
+ vm.save()
542
+
543
+
544
+ class VMComparisonView(View):
545
+ """Compare vCenter VMs with NetBox VMs."""
546
+
547
+ template_name = "netbox_vcenter/compare.html"
548
+
549
+ def get(self, request):
550
+ """Show comparison between vCenter and NetBox VMs."""
551
+ server = request.GET.get("server", "")
552
+
553
+ # Get name matching config
554
+ match_mode, match_pattern = get_name_match_config()
555
+
556
+ # Get vCenter VMs from cache
557
+ cached_data = get_cached_data(server) if server else None
558
+ vcenter_vms = cached_data.get("vms", []) if cached_data else []
559
+
560
+ # Build maps: normalized_name -> original data
561
+ vcenter_normalized_map = {} # normalized -> vm_data
562
+ for vm in vcenter_vms:
563
+ name = vm.get("name", "")
564
+ normalized = normalize_name(name, match_mode, match_pattern)
565
+ vcenter_normalized_map[normalized] = vm
566
+
567
+ # Get NetBox VMs
568
+ netbox_vms = VirtualMachine.objects.all()
569
+ netbox_normalized_map = {} # normalized -> vm_object
570
+ for vm in netbox_vms:
571
+ normalized = normalize_name(vm.name, match_mode, match_pattern)
572
+ netbox_normalized_map[normalized] = vm
573
+
574
+ vcenter_normalized = set(vcenter_normalized_map.keys())
575
+ netbox_normalized = set(netbox_normalized_map.keys())
576
+
577
+ # Categorize VMs by normalized names
578
+ in_both = vcenter_normalized & netbox_normalized
579
+ only_in_vcenter = vcenter_normalized - netbox_normalized
580
+ only_in_netbox = netbox_normalized - vcenter_normalized
581
+
582
+ # Build comparison lists
583
+ comparison = {
584
+ "in_both": [],
585
+ "only_in_vcenter": [],
586
+ "only_in_netbox": [],
587
+ }
588
+
589
+ # VMs in both - check for spec differences
590
+ for normalized in sorted(in_both):
591
+ vc_vm = vcenter_normalized_map.get(normalized, {})
592
+ nb_vm = netbox_normalized_map.get(normalized)
593
+
594
+ diff = {
595
+ "name": vc_vm.get("name", normalized), # Show original vCenter name
596
+ "netbox_name": nb_vm.name if nb_vm else None, # Show NetBox name if different
597
+ "vcenter": vc_vm,
598
+ "netbox": {
599
+ "vcpus": nb_vm.vcpus if nb_vm else None,
600
+ "memory_mb": nb_vm.memory if nb_vm else None,
601
+ "disk_gb": nb_vm.disk if nb_vm else None,
602
+ "status": nb_vm.status if nb_vm else None,
603
+ },
604
+ "has_differences": False,
605
+ }
606
+
607
+ # Check for differences
608
+ if vc_vm.get("vcpus") != diff["netbox"]["vcpus"]:
609
+ diff["has_differences"] = True
610
+ if vc_vm.get("memory_mb") != diff["netbox"]["memory_mb"]:
611
+ diff["has_differences"] = True
612
+ if vc_vm.get("disk_gb") != diff["netbox"]["disk_gb"]:
613
+ diff["has_differences"] = True
614
+
615
+ comparison["in_both"].append(diff)
616
+
617
+ # VMs only in vCenter
618
+ for normalized in sorted(only_in_vcenter):
619
+ vc_vm = vcenter_normalized_map.get(normalized, {})
620
+ comparison["only_in_vcenter"].append(vc_vm if vc_vm else {"name": normalized})
621
+
622
+ # VMs only in NetBox
623
+ for normalized in sorted(only_in_netbox):
624
+ nb_vm = netbox_normalized_map.get(normalized)
625
+ comparison["only_in_netbox"].append(
626
+ {
627
+ "name": nb_vm.name if nb_vm else normalized,
628
+ "vcpus": nb_vm.vcpus if nb_vm else None,
629
+ "memory_mb": nb_vm.memory if nb_vm else None,
630
+ "cluster": nb_vm.cluster.name if nb_vm and nb_vm.cluster else None,
631
+ }
632
+ )
633
+
634
+ # Get all servers for selector
635
+ config = settings.PLUGINS_CONFIG.get("netbox_vcenter", {})
636
+ servers = config.get("vcenter_servers", [])
637
+
638
+ return render(
639
+ request,
640
+ self.template_name,
641
+ {
642
+ "server": server,
643
+ "servers": servers,
644
+ "cached_data": cached_data,
645
+ "comparison": comparison,
646
+ "in_both_count": len(comparison["in_both"]),
647
+ "only_vcenter_count": len(comparison["only_in_vcenter"]),
648
+ "only_netbox_count": len(comparison["only_in_netbox"]),
649
+ "diff_count": len([c for c in comparison["in_both"] if c.get("has_differences")]),
650
+ },
651
+ )
652
+
653
+
654
+ class SyncDifferencesView(View):
655
+ """Sync all VMs with spec differences from vCenter to NetBox."""
656
+
657
+ def post(self, request):
658
+ """Sync VMs that have differences between vCenter and NetBox."""
659
+ server = request.POST.get("server", "")
660
+
661
+ if not server:
662
+ messages.error(request, "No server specified")
663
+ return redirect("plugins:netbox_vcenter:compare")
664
+
665
+ # Get cached vCenter data
666
+ cached_data = get_cached_data(server)
667
+ if not cached_data:
668
+ messages.error(request, f"No cached data for {server}. Please sync first.")
669
+ return redirect(f"/plugins/vcenter/compare/?server={server}")
670
+
671
+ vcenter_vms = cached_data.get("vms", [])
672
+
673
+ # Get name matching config
674
+ match_mode, match_pattern = get_name_match_config()
675
+
676
+ # Get import config for tag/role/platform
677
+ import_config = get_import_config()
678
+ default_tag_slug = import_config["default_tag"]
679
+
680
+ default_tag = None
681
+ if default_tag_slug:
682
+ try:
683
+ default_tag = Tag.objects.get(slug=default_tag_slug)
684
+ except Tag.DoesNotExist:
685
+ pass
686
+
687
+ # Build maps
688
+ vcenter_normalized_map = {}
689
+ for vm in vcenter_vms:
690
+ name = vm.get("name", "")
691
+ normalized = normalize_name(name, match_mode, match_pattern)
692
+ vcenter_normalized_map[normalized] = vm
693
+
694
+ netbox_normalized_map = {}
695
+ for vm in VirtualMachine.objects.all():
696
+ normalized = normalize_name(vm.name, match_mode, match_pattern)
697
+ netbox_normalized_map[normalized] = vm
698
+
699
+ # Find VMs in both
700
+ in_both = set(vcenter_normalized_map.keys()) & set(netbox_normalized_map.keys())
701
+
702
+ updated = 0
703
+ errors = []
704
+
705
+ for normalized in in_both:
706
+ vc_vm = vcenter_normalized_map.get(normalized)
707
+ nb_vm = netbox_normalized_map.get(normalized)
708
+
709
+ if not vc_vm or not nb_vm:
710
+ continue
711
+
712
+ # Check if there are differences
713
+ has_diff = (
714
+ vc_vm.get("vcpus") != nb_vm.vcpus
715
+ or vc_vm.get("memory_mb") != nb_vm.memory
716
+ or vc_vm.get("disk_gb") != nb_vm.disk
717
+ )
718
+
719
+ if not has_diff:
720
+ continue
721
+
722
+ try:
723
+ # Update NetBox VM with vCenter specs
724
+ nb_vm.vcpus = vc_vm.get("vcpus")
725
+ nb_vm.memory = vc_vm.get("memory_mb")
726
+ nb_vm.disk = vc_vm.get("disk_gb")
727
+ nb_vm.status = "active" if vc_vm.get("power_state") == "on" else "offline"
728
+
729
+ nb_vm.full_clean()
730
+ nb_vm.save()
731
+
732
+ # Add tag if configured
733
+ if default_tag:
734
+ nb_vm.tags.add(default_tag)
735
+
736
+ updated += 1
737
+ except Exception as e:
738
+ errors.append(f"{nb_vm.name}: {str(e)}")
739
+ logger.error(f"Error syncing VM {nb_vm.name}: {e}")
740
+
741
+ if updated:
742
+ messages.success(request, f"Synced {updated} VM(s) from {server}")
743
+ if errors:
744
+ messages.warning(request, f"Failed: {len(errors)} VM(s): {'; '.join(errors[:3])}")
745
+ if not updated and not errors:
746
+ messages.info(request, "No VMs needed syncing")
747
+
748
+ return redirect(f"/plugins/vcenter/compare/?server={server}")