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.
- netbox_vcenter/__init__.py +59 -0
- netbox_vcenter/api/__init__.py +1 -0
- netbox_vcenter/api/urls.py +5 -0
- netbox_vcenter/client.py +247 -0
- netbox_vcenter/forms.py +77 -0
- netbox_vcenter/navigation.py +25 -0
- netbox_vcenter/templates/netbox_vcenter/compare.html +219 -0
- netbox_vcenter/templates/netbox_vcenter/dashboard.html +311 -0
- netbox_vcenter/templates/netbox_vcenter/import.html +182 -0
- netbox_vcenter/templatetags/__init__.py +0 -0
- netbox_vcenter/templatetags/vcenter_tags.py +16 -0
- netbox_vcenter/urls.py +13 -0
- netbox_vcenter/views.py +748 -0
- netbox_vcenter_server-0.2.0.dist-info/METADATA +200 -0
- netbox_vcenter_server-0.2.0.dist-info/RECORD +18 -0
- netbox_vcenter_server-0.2.0.dist-info/WHEEL +5 -0
- netbox_vcenter_server-0.2.0.dist-info/licenses/LICENSE +190 -0
- netbox_vcenter_server-0.2.0.dist-info/top_level.txt +1 -0
netbox_vcenter/views.py
ADDED
|
@@ -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}")
|