1193 lines
42 KiB
Python
1193 lines
42 KiB
Python
import ipaddress
|
|
import re
|
|
import ssl
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from urllib.parse import urlsplit
|
|
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.db import transaction
|
|
from django.db.models import Sum
|
|
from dcim.models import MACAddress, Platform
|
|
from ipam.models import IPAddress
|
|
from virtualization.models import VMInterface, VirtualDisk, VirtualMachine
|
|
|
|
from .choices import EndpointAuthMethodChoices, EndpointProviderChoices
|
|
|
|
try:
|
|
import requests
|
|
from requests.adapters import HTTPAdapter
|
|
from urllib3.exceptions import InsecureRequestWarning
|
|
except ImportError: # pragma: no cover - handled at runtime inside NetBox
|
|
HTTPAdapter = None
|
|
requests = None
|
|
InsecureRequestWarning = None
|
|
|
|
try:
|
|
from proxmox_sdk import ProxmoxSDK
|
|
from proxmox_sdk.sdk.exceptions import (
|
|
ProxmoxConnectionError as SDKConnectionError,
|
|
ProxmoxTimeoutError as SDKTimeoutError,
|
|
ResourceException as SDKResourceException,
|
|
)
|
|
except ImportError: # pragma: no cover - optional compatibility fallback
|
|
ProxmoxSDK = None
|
|
SDKConnectionError = None
|
|
SDKTimeoutError = None
|
|
SDKResourceException = None
|
|
|
|
try:
|
|
from pyVim.connect import Disconnect, SmartConnect
|
|
from pyVmomi import vim
|
|
except ImportError: # pragma: no cover - handled at runtime inside NetBox
|
|
Disconnect = None
|
|
SmartConnect = None
|
|
vim = None
|
|
|
|
|
|
@dataclass
|
|
class InterfaceData:
|
|
name: str
|
|
mac_address: str
|
|
ip_addresses: list[str] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class DiskData:
|
|
name: str
|
|
size_mb: int
|
|
description: str = ""
|
|
|
|
|
|
@dataclass
|
|
class VMData:
|
|
name: str
|
|
status: str
|
|
vcpus: int
|
|
memory_mb: int
|
|
disk_mb: int
|
|
guest_os: str = ""
|
|
disks: list[DiskData] = field(default_factory=list)
|
|
interfaces: list[InterfaceData] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class SyncResult:
|
|
seen: int = 0
|
|
synced: int = 0
|
|
skipped: int = 0
|
|
created: int = 0
|
|
updated: int = 0
|
|
errors: int = 0
|
|
interfaces_created: int = 0
|
|
interfaces_updated: int = 0
|
|
ip_addresses_created: int = 0
|
|
ip_addresses_updated: int = 0
|
|
ip_conflicts: int = 0
|
|
mac_addresses_created: int = 0
|
|
mac_addresses_updated: int = 0
|
|
mac_addresses_deleted: int = 0
|
|
virtual_disks_created: int = 0
|
|
virtual_disks_updated: int = 0
|
|
virtual_disks_deleted: int = 0
|
|
|
|
|
|
class VMwareConnectionError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class ProxmoxConnectionError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class ProxmoxTLSCompatibilityAdapter(HTTPAdapter):
|
|
def __init__(self, validate_ssl=True, *args, **kwargs):
|
|
self.validate_ssl = validate_ssl
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs):
|
|
pool_kwargs["ssl_context"] = self._build_ssl_context()
|
|
return super().init_poolmanager(connections, maxsize, block=block, **pool_kwargs)
|
|
|
|
def proxy_manager_for(self, *args, **kwargs):
|
|
kwargs["ssl_context"] = self._build_ssl_context()
|
|
return super().proxy_manager_for(*args, **kwargs)
|
|
|
|
def _build_ssl_context(self):
|
|
context = ssl.create_default_context()
|
|
if hasattr(ssl, "TLSVersion"):
|
|
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
context.maximum_version = ssl.TLSVersion.TLSv1_2
|
|
if not self.validate_ssl:
|
|
context.check_hostname = False
|
|
context.verify_mode = ssl.CERT_NONE
|
|
return context
|
|
|
|
|
|
class VMwareClient:
|
|
def __init__(self, endpoint):
|
|
self.endpoint = endpoint
|
|
self.service_instance = None
|
|
|
|
def __enter__(self):
|
|
if SmartConnect is None:
|
|
raise VMwareConnectionError("pyVmomi is not installed in the NetBox Python environment.")
|
|
|
|
ssl_context = None
|
|
if not self.endpoint.validate_ssl:
|
|
ssl_context = ssl._create_unverified_context()
|
|
|
|
self.service_instance = SmartConnect(
|
|
host=self.endpoint.host,
|
|
port=self.endpoint.port,
|
|
user=self.endpoint.username,
|
|
pwd=self.endpoint.password,
|
|
sslContext=ssl_context,
|
|
)
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_value, traceback):
|
|
if self.service_instance is not None and Disconnect is not None:
|
|
Disconnect(self.service_instance)
|
|
|
|
def iter_virtual_machines(self):
|
|
content = self.service_instance.RetrieveContent()
|
|
container_view = content.viewManager.CreateContainerView(
|
|
content.rootFolder,
|
|
[vim.VirtualMachine],
|
|
True,
|
|
)
|
|
|
|
try:
|
|
for vm_obj in container_view.view:
|
|
yield self._build_vm_data(vm_obj)
|
|
finally:
|
|
container_view.Destroy()
|
|
|
|
def _build_vm_data(self, vm_obj):
|
|
config = getattr(vm_obj, "config", None)
|
|
hardware = getattr(config, "hardware", None)
|
|
|
|
memory_mb = int(getattr(hardware, "memoryMB", 0) or 0)
|
|
vcpus = int(getattr(hardware, "numCPU", 0) or 0)
|
|
disks = self._get_disks(hardware)
|
|
disk_mb = sum(disk.size_mb for disk in disks)
|
|
guest_os = getattr(config, "guestFullName", "") or ""
|
|
power_state = str(getattr(getattr(vm_obj, "runtime", None), "powerState", ""))
|
|
status = "active" if "poweredOn" in power_state else "offline"
|
|
|
|
return VMData(
|
|
name=vm_obj.name,
|
|
status=status,
|
|
vcpus=vcpus,
|
|
memory_mb=memory_mb,
|
|
disk_mb=disk_mb,
|
|
guest_os=guest_os,
|
|
disks=disks,
|
|
interfaces=self._get_interfaces(vm_obj),
|
|
)
|
|
|
|
@staticmethod
|
|
def _get_disks(hardware):
|
|
disks = []
|
|
used_names = set()
|
|
disk_index = 0
|
|
|
|
for device in getattr(hardware, "device", []) or []:
|
|
if isinstance(device, vim.vm.device.VirtualDisk):
|
|
disk_index += 1
|
|
name = VMwareClient._unique_disk_name(device, disk_index, used_names)
|
|
disks.append(
|
|
DiskData(
|
|
name=name,
|
|
size_mb=round((device.capacityInKB or 0) / 1024),
|
|
description=VMwareClient._disk_description(device),
|
|
)
|
|
)
|
|
|
|
return disks
|
|
|
|
@staticmethod
|
|
def _unique_disk_name(device, disk_index, used_names):
|
|
device_info = getattr(device, "deviceInfo", None)
|
|
base_name = getattr(device_info, "label", None) or f"Hard disk {disk_index}"
|
|
base_name = VMwareClient._truncate_component_name(str(base_name).strip() or f"Hard disk {disk_index}")
|
|
|
|
name = base_name
|
|
suffix = 2
|
|
while name in used_names:
|
|
suffix_text = f" ({suffix})"
|
|
name = f"{base_name[:64 - len(suffix_text)]}{suffix_text}"
|
|
suffix += 1
|
|
|
|
used_names.add(name)
|
|
return name
|
|
|
|
@staticmethod
|
|
def _truncate_component_name(value):
|
|
return value[:64]
|
|
|
|
@staticmethod
|
|
def _disk_description(device):
|
|
backing = getattr(device, "backing", None)
|
|
filename = getattr(backing, "fileName", None)
|
|
thin_provisioned = getattr(backing, "thinProvisioned", None)
|
|
|
|
parts = []
|
|
if filename:
|
|
parts.append(str(filename))
|
|
if thin_provisioned is not None:
|
|
parts.append("thin" if thin_provisioned else "thick")
|
|
|
|
detail = ", ".join(parts) if parts else f"key={getattr(device, 'key', 'unknown')}"
|
|
return f"VMware: {detail}"[:200]
|
|
|
|
@staticmethod
|
|
def _get_interfaces(vm_obj):
|
|
interfaces = []
|
|
guest = getattr(vm_obj, "guest", None)
|
|
|
|
for network in getattr(guest, "net", []) or []:
|
|
mac_address = getattr(network, "macAddress", None)
|
|
if not mac_address:
|
|
continue
|
|
|
|
name = getattr(network, "device", None) or f"NIC-{mac_address[-5:].replace(':', '')}"
|
|
interfaces.append(
|
|
InterfaceData(
|
|
name=name,
|
|
mac_address=mac_address,
|
|
ip_addresses=list(getattr(network, "ipAddress", []) or []),
|
|
)
|
|
)
|
|
|
|
return interfaces
|
|
|
|
|
|
class ProxmoxClient:
|
|
DISK_KEY_PATTERN = re.compile(r"^(ide|sata|scsi|virtio)\d+$")
|
|
LXC_DISK_KEY_PATTERN = re.compile(r"^(rootfs|mp\d+)$")
|
|
NET_KEY_PATTERN = re.compile(r"^net\d+$")
|
|
FULL_TOKEN_PATTERN = re.compile(r"^(?:PVEAPIToken=)?(?P<user>[^!]+)!(?P<name>[^=]+)=(?P<value>.+)$")
|
|
RETRY_STATUSES = {502, 503, 504}
|
|
|
|
def __init__(self, endpoint):
|
|
self.endpoint = endpoint
|
|
self.host, self.port = self._parse_endpoint_address(endpoint.host, endpoint.port)
|
|
self.url_host = self._format_host_for_url(self.host)
|
|
self.session = None
|
|
self.sdk = None
|
|
self.uses_sdk = False
|
|
self.base_url = f"https://{self.url_host}:{self.port}/api2/json"
|
|
self.connect_timeout = endpoint.connect_timeout_seconds or 10
|
|
self.read_timeout = endpoint.request_timeout_seconds or 120
|
|
self.timeout = (self.connect_timeout, self.read_timeout)
|
|
self.max_retries = endpoint.max_retries or 0
|
|
self.retry_backoff = float(endpoint.retry_backoff_seconds or 0.5)
|
|
self.api_probe_succeeded = False
|
|
self.api_probe_error = ""
|
|
|
|
def __enter__(self):
|
|
if "@" not in self.endpoint.username:
|
|
raise ProxmoxConnectionError("Proxmox username must include a realm, e.g. root@pam or user@pve.")
|
|
|
|
if ProxmoxSDK is not None:
|
|
self._connect_with_sdk()
|
|
return self
|
|
|
|
self._connect_with_requests()
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_value, traceback):
|
|
if self.sdk is not None:
|
|
self.sdk.close()
|
|
if self.session is not None:
|
|
self.session.close()
|
|
|
|
@staticmethod
|
|
def _parse_endpoint_address(host, port):
|
|
raw_host = str(host or "").strip()
|
|
resolved_port = int(port or 8006)
|
|
if not raw_host:
|
|
return raw_host, resolved_port
|
|
|
|
parsed = urlsplit(raw_host if "://" in raw_host else f"//{raw_host}")
|
|
parsed_host = parsed.hostname
|
|
if parsed.port:
|
|
resolved_port = parsed.port
|
|
|
|
if parsed_host:
|
|
return parsed_host.strip("[]"), resolved_port
|
|
|
|
return raw_host.strip("[]"), resolved_port
|
|
|
|
@staticmethod
|
|
def _format_host_for_url(host):
|
|
if ":" in host and not host.startswith("["):
|
|
return f"[{host}]"
|
|
return host
|
|
|
|
def _connect_with_sdk(self):
|
|
user = (self.endpoint.username or "").strip()
|
|
kwargs = {
|
|
"host": self.host,
|
|
"backend": "https",
|
|
"service": "PVE",
|
|
"port": self.port,
|
|
"user": user,
|
|
"verify_ssl": self.endpoint.validate_ssl,
|
|
"timeout": self.read_timeout,
|
|
"connect_timeout": self.connect_timeout,
|
|
"max_retries": self.max_retries,
|
|
"retry_backoff": self.retry_backoff,
|
|
}
|
|
|
|
if self.endpoint.auth_method == EndpointAuthMethodChoices.METHOD_API_TOKEN:
|
|
user, token_name, token_value = self._normalized_token_credentials()
|
|
kwargs["user"] = user
|
|
kwargs.update(
|
|
{
|
|
"token_name": token_name,
|
|
"token_value": token_value,
|
|
}
|
|
)
|
|
else:
|
|
kwargs["password"] = self.endpoint.password
|
|
|
|
try:
|
|
self.sdk = ProxmoxSDK.sync(**kwargs)
|
|
self.uses_sdk = True
|
|
self._get("version")
|
|
self.api_probe_succeeded = True
|
|
except ProxmoxConnectionError:
|
|
if self.sdk is not None:
|
|
self.sdk.close()
|
|
self.sdk = None
|
|
raise
|
|
except Exception as exc:
|
|
if self.sdk is not None:
|
|
self.sdk.close()
|
|
self.sdk = None
|
|
raise ProxmoxConnectionError(self._describe_sdk_error("GET", "version", exc)) from exc
|
|
|
|
def _connect_with_requests(self):
|
|
if requests is None:
|
|
raise ProxmoxConnectionError(
|
|
"Neither proxmox-sdk nor requests is installed in the NetBox Python environment."
|
|
)
|
|
|
|
self.session = requests.Session()
|
|
self.session.trust_env = False
|
|
self.session.verify = self.endpoint.validate_ssl
|
|
if HTTPAdapter is not None:
|
|
self.session.mount("https://", ProxmoxTLSCompatibilityAdapter(validate_ssl=self.endpoint.validate_ssl))
|
|
self.session.headers.update(
|
|
{
|
|
"Accept": "application/json",
|
|
"Connection": "close",
|
|
"User-Agent": "netbox-vm-import/0.2",
|
|
}
|
|
)
|
|
if not self.endpoint.validate_ssl and InsecureRequestWarning is not None:
|
|
requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning)
|
|
|
|
self._probe_api_availability()
|
|
|
|
if self.endpoint.auth_method == EndpointAuthMethodChoices.METHOD_API_TOKEN:
|
|
user, token_name, token_value = self._normalized_token_credentials()
|
|
self.session.headers.update({"Authorization": f"PVEAPIToken={user}!{token_name}={token_value}"})
|
|
else:
|
|
self._authenticate_with_password()
|
|
|
|
def _probe_api_availability(self):
|
|
try:
|
|
self._request("GET", "version", timeout=(5, 10))
|
|
self.api_probe_succeeded = True
|
|
self.api_probe_error = ""
|
|
except ProxmoxConnectionError as exc:
|
|
self.api_probe_succeeded = False
|
|
self.api_probe_error = str(exc)
|
|
|
|
def _normalized_token_credentials(self):
|
|
user = (self.endpoint.username or "").strip()
|
|
token_name = (self.endpoint.token_name or "").strip()
|
|
token_value = (self.endpoint.password or "").strip()
|
|
|
|
parsed = self._parse_full_token(token_value)
|
|
if parsed is not None:
|
|
user, token_name, token_value = parsed
|
|
else:
|
|
parsed = self._parse_full_token(token_name)
|
|
if parsed is not None:
|
|
parsed_user, parsed_name, parsed_value = parsed
|
|
user = parsed_user or user
|
|
token_name = parsed_name
|
|
if parsed_value and not token_value:
|
|
token_value = parsed_value
|
|
elif token_name.startswith("PVEAPIToken="):
|
|
token_name = token_name.removeprefix("PVEAPIToken=").strip()
|
|
|
|
if "!" in token_name:
|
|
parsed_user, parsed_name = token_name.rsplit("!", 1)
|
|
if parsed_user:
|
|
user = parsed_user
|
|
token_name = parsed_name
|
|
|
|
if not token_name or not token_value:
|
|
raise ProxmoxConnectionError(
|
|
"Proxmox API token authentication requires a token name and token secret."
|
|
)
|
|
|
|
return user, token_name, token_value
|
|
|
|
@classmethod
|
|
def _parse_full_token(cls, value):
|
|
match = cls.FULL_TOKEN_PATTERN.match(str(value or "").strip())
|
|
if not match:
|
|
return None
|
|
return match.group("user").strip(), match.group("name").strip(), match.group("value").strip()
|
|
|
|
def _authenticate_with_password(self):
|
|
response = self._request(
|
|
"POST",
|
|
"access/ticket",
|
|
data={
|
|
"username": self.endpoint.username,
|
|
"password": self.endpoint.password,
|
|
},
|
|
)
|
|
data = response.json().get("data") or {}
|
|
ticket = data.get("ticket")
|
|
csrf_token = data.get("CSRFPreventionToken")
|
|
|
|
if not ticket:
|
|
raise ProxmoxConnectionError("Proxmox authentication did not return a ticket.")
|
|
|
|
self.session.cookies.set("PVEAuthCookie", ticket)
|
|
if csrf_token:
|
|
self.session.headers.update({"CSRFPreventionToken": csrf_token})
|
|
|
|
def _url(self, path):
|
|
return f"{self.base_url}/{path.strip('/')}"
|
|
|
|
def _request(self, method, path, timeout=None, **kwargs):
|
|
url = self._url(path)
|
|
request_timeout = timeout or self.timeout
|
|
attempts = self.max_retries + 1 if method.upper() == "GET" else 1
|
|
last_error = None
|
|
|
|
for attempt in range(attempts):
|
|
if attempt:
|
|
time.sleep(min(self.retry_backoff * (2 ** (attempt - 1)), 30.0))
|
|
try:
|
|
response = self.session.request(method, url, timeout=request_timeout, **kwargs)
|
|
if response.status_code in self.RETRY_STATUSES and attempt < attempts - 1:
|
|
last_error = ProxmoxConnectionError(
|
|
f"Proxmox API {method} {path} returned transient HTTP {response.status_code}."
|
|
)
|
|
continue
|
|
response.raise_for_status()
|
|
return response
|
|
except requests.ConnectTimeout as exc:
|
|
last_error = ProxmoxConnectionError(
|
|
f"Proxmox API {method} {path} connect timed out after {request_timeout[0]} seconds."
|
|
)
|
|
if attempt >= attempts - 1:
|
|
raise last_error from exc
|
|
except requests.ReadTimeout as exc:
|
|
detail = f"Proxmox API {method} {path} read timed out after {request_timeout[1]} seconds."
|
|
if path == "access/ticket" and self.api_probe_succeeded:
|
|
detail += " API version endpoint responded, so the delay is likely in Proxmox authentication. Check username realm (e.g. root@pam), PAM/LDAP auth, two-factor auth, or use an API token."
|
|
elif getattr(self, "api_probe_error", "") and path != "version":
|
|
detail += f" Earlier unauthenticated version probe also failed: {self.api_probe_error}"
|
|
last_error = ProxmoxConnectionError(detail)
|
|
if attempt >= attempts - 1:
|
|
raise last_error from exc
|
|
except requests.exceptions.SSLError as exc:
|
|
raise ProxmoxConnectionError(
|
|
f"Proxmox API {method} {path} TLS validation failed. Disable Validate SSL for self-signed certificates or install the CA certificate. Details: {exc}"
|
|
) from exc
|
|
except requests.RequestException as exc:
|
|
last_error = ProxmoxConnectionError(f"Proxmox API {method} {path} failed: {exc}")
|
|
if attempt >= attempts - 1:
|
|
raise last_error from exc
|
|
|
|
if last_error is not None:
|
|
raise last_error
|
|
raise ProxmoxConnectionError(f"Proxmox API {method} {path} failed without a response.")
|
|
|
|
def _sdk_get(self, path, params=None):
|
|
try:
|
|
return self.sdk(path.strip("/")).get(**(params or {}))
|
|
except Exception as exc:
|
|
raise ProxmoxConnectionError(self._describe_sdk_error("GET", path, exc)) from exc
|
|
|
|
def _describe_sdk_error(self, method, path, exc):
|
|
if SDKTimeoutError is not None and isinstance(exc, SDKTimeoutError):
|
|
return f"Proxmox SDK {method} {path} timed out after {self.read_timeout} seconds."
|
|
|
|
if SDKConnectionError is not None and isinstance(exc, SDKConnectionError):
|
|
detail = str(exc).strip() or "connection failed"
|
|
return f"Proxmox SDK {method} {path} connection failed: {detail}"
|
|
|
|
if SDKResourceException is not None and isinstance(exc, SDKResourceException):
|
|
status_code = getattr(exc, "status_code", None)
|
|
status_message = getattr(exc, "status_message", "") or ""
|
|
content = (getattr(exc, "content", "") or "").strip()
|
|
errors = getattr(exc, "errors", None)
|
|
parts = []
|
|
if status_code:
|
|
parts.append(f"HTTP {status_code} {status_message}".strip())
|
|
elif status_message:
|
|
parts.append(status_message)
|
|
if content:
|
|
parts.append(content)
|
|
if errors:
|
|
parts.append(str(errors))
|
|
detail = " - ".join(parts) if parts else str(exc)
|
|
return f"Proxmox SDK {method} {path} failed: {detail}"
|
|
|
|
detail = str(exc).strip() or "unknown error"
|
|
return f"Proxmox SDK {method} {path} failed: {detail}"
|
|
|
|
def _get(self, path, params=None):
|
|
if self.uses_sdk:
|
|
return self._sdk_get(path, params=params)
|
|
response = self._request("GET", path, params=params)
|
|
return response.json().get("data")
|
|
|
|
def iter_virtual_machines(self):
|
|
resources = self._get("cluster/resources", params={"type": "vm"}) or []
|
|
|
|
for resource in resources:
|
|
vm_type = resource.get("type")
|
|
if vm_type == "lxc" and not self.endpoint.sync_lxc_containers:
|
|
continue
|
|
if vm_type not in ("qemu", "lxc"):
|
|
continue
|
|
|
|
yield self._build_vm_data(resource)
|
|
|
|
def _build_vm_data(self, resource):
|
|
vmid = resource["vmid"]
|
|
node = resource["node"]
|
|
vm_type = resource["type"]
|
|
config = self._get(f"nodes/{node}/{vm_type}/{vmid}/config") or {}
|
|
|
|
name = resource.get("name") or config.get("name") or config.get("hostname") or f"{vm_type}-{vmid}"
|
|
status = "active" if resource.get("status") == "running" else "offline"
|
|
memory_mb = self._get_memory_mb(resource, config)
|
|
vcpus = self._get_vcpus(resource, config)
|
|
|
|
if vm_type == "qemu":
|
|
disks = self._get_qemu_disks(config)
|
|
interfaces = self._get_qemu_interfaces(config)
|
|
self._merge_qemu_agent_interfaces(node, vmid, interfaces)
|
|
guest_os = self._get_qemu_guest_os(node, vmid)
|
|
else:
|
|
disks = self._get_lxc_disks(config)
|
|
interfaces = self._get_lxc_interfaces(config)
|
|
guest_os = "Linux Container"
|
|
|
|
disk_mb = sum(disk.size_mb for disk in disks) or self._bytes_to_mb(resource.get("maxdisk"))
|
|
|
|
return VMData(
|
|
name=str(name),
|
|
status=status,
|
|
vcpus=vcpus,
|
|
memory_mb=memory_mb,
|
|
disk_mb=disk_mb,
|
|
guest_os=guest_os,
|
|
disks=disks,
|
|
interfaces=interfaces,
|
|
)
|
|
|
|
@staticmethod
|
|
def _get_memory_mb(resource, config):
|
|
memory = config.get("memory")
|
|
if memory not in (None, ""):
|
|
try:
|
|
return int(memory)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
return ProxmoxClient._bytes_to_mb(resource.get("maxmem"))
|
|
|
|
@staticmethod
|
|
def _get_vcpus(resource, config):
|
|
try:
|
|
sockets = int(config.get("sockets") or 1)
|
|
cores = int(config.get("cores") or resource.get("maxcpu") or 0)
|
|
return max(sockets * cores, 0)
|
|
except (TypeError, ValueError):
|
|
return int(resource.get("maxcpu") or 0)
|
|
|
|
@staticmethod
|
|
def _bytes_to_mb(value):
|
|
try:
|
|
return round(int(value or 0) / 1024 / 1024)
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
def _get_qemu_disks(self, config):
|
|
disks = []
|
|
|
|
for key, value in sorted(config.items()):
|
|
if not self.DISK_KEY_PATTERN.match(key):
|
|
continue
|
|
parsed = self._parse_proxmox_value(value)
|
|
if parsed.get("media") == "cdrom":
|
|
continue
|
|
|
|
size_mb = self._parse_size_mb(parsed.get("size"))
|
|
if size_mb <= 0:
|
|
continue
|
|
|
|
disks.append(
|
|
DiskData(
|
|
name=key,
|
|
size_mb=size_mb,
|
|
description=f"Proxmox: {str(value)[:191]}",
|
|
)
|
|
)
|
|
|
|
return disks
|
|
|
|
def _get_lxc_disks(self, config):
|
|
disks = []
|
|
|
|
for key, value in sorted(config.items()):
|
|
if not self.LXC_DISK_KEY_PATTERN.match(key):
|
|
continue
|
|
|
|
parsed = self._parse_proxmox_value(value)
|
|
size_mb = self._parse_size_mb(parsed.get("size"))
|
|
if size_mb <= 0:
|
|
continue
|
|
|
|
disks.append(
|
|
DiskData(
|
|
name=key,
|
|
size_mb=size_mb,
|
|
description=f"Proxmox: {str(value)[:191]}",
|
|
)
|
|
)
|
|
|
|
return disks
|
|
|
|
def _get_qemu_interfaces(self, config):
|
|
interfaces = []
|
|
|
|
for key, value in sorted(config.items()):
|
|
if not self.NET_KEY_PATTERN.match(key):
|
|
continue
|
|
|
|
parsed = self._parse_proxmox_value(value)
|
|
mac_address = self._extract_proxmox_mac(parsed)
|
|
if not mac_address:
|
|
continue
|
|
|
|
bridge = parsed.get("bridge")
|
|
name = f"{key} ({bridge})" if bridge else key
|
|
interfaces.append(InterfaceData(name=name[:64], mac_address=mac_address))
|
|
|
|
return interfaces
|
|
|
|
def _get_lxc_interfaces(self, config):
|
|
interfaces = []
|
|
|
|
for key, value in sorted(config.items()):
|
|
if not self.NET_KEY_PATTERN.match(key):
|
|
continue
|
|
|
|
parsed = self._parse_proxmox_value(value)
|
|
mac_address = parsed.get("hwaddr")
|
|
if not mac_address:
|
|
continue
|
|
|
|
name = parsed.get("name") or key
|
|
ip_addresses = []
|
|
for ip_key in ("ip", "ip6"):
|
|
ip_value = parsed.get(ip_key)
|
|
if ip_value and ip_value not in ("dhcp", "manual", "auto"):
|
|
ip_addresses.append(ip_value)
|
|
|
|
interfaces.append(
|
|
InterfaceData(
|
|
name=str(name)[:64],
|
|
mac_address=mac_address,
|
|
ip_addresses=ip_addresses,
|
|
)
|
|
)
|
|
|
|
return interfaces
|
|
|
|
def _merge_qemu_agent_interfaces(self, node, vmid, interfaces):
|
|
try:
|
|
response = self._get(f"nodes/{node}/qemu/{vmid}/agent/network-get-interfaces") or {}
|
|
except (requests.RequestException, ProxmoxConnectionError):
|
|
return
|
|
|
|
agent_interfaces = response.get("result") if isinstance(response, dict) else response
|
|
if not isinstance(agent_interfaces, list):
|
|
return
|
|
|
|
interfaces_by_mac = {self._normalize_mac(interface.mac_address): interface for interface in interfaces}
|
|
|
|
for agent_interface in agent_interfaces:
|
|
mac_address = agent_interface.get("hardware-address")
|
|
normalized_mac = self._normalize_mac(mac_address)
|
|
if not normalized_mac:
|
|
continue
|
|
|
|
ip_addresses = []
|
|
for ip_data in agent_interface.get("ip-addresses") or []:
|
|
ip_address = ip_data.get("ip-address")
|
|
prefix = ip_data.get("prefix")
|
|
if not ip_address:
|
|
continue
|
|
if prefix is not None:
|
|
ip_addresses.append(f"{ip_address}/{prefix}")
|
|
else:
|
|
ip_addresses.append(ip_address)
|
|
|
|
if normalized_mac in interfaces_by_mac:
|
|
interfaces_by_mac[normalized_mac].ip_addresses = ip_addresses
|
|
else:
|
|
interfaces.append(
|
|
InterfaceData(
|
|
name=str(agent_interface.get("name") or f"NIC-{normalized_mac[-5:]}")[:64],
|
|
mac_address=normalized_mac,
|
|
ip_addresses=ip_addresses,
|
|
)
|
|
)
|
|
|
|
def _get_qemu_guest_os(self, node, vmid):
|
|
try:
|
|
response = self._get(f"nodes/{node}/qemu/{vmid}/agent/get-osinfo") or {}
|
|
except (requests.RequestException, ProxmoxConnectionError):
|
|
return ""
|
|
|
|
result = response.get("result") if isinstance(response, dict) else None
|
|
if not isinstance(result, dict):
|
|
return ""
|
|
|
|
return result.get("pretty-name") or result.get("name") or ""
|
|
|
|
@staticmethod
|
|
def _parse_proxmox_value(value):
|
|
parsed = {"_raw": str(value)}
|
|
parts = str(value).split(",")
|
|
if parts:
|
|
parsed["_first"] = parts[0]
|
|
|
|
for part in parts:
|
|
if "=" in part:
|
|
key, part_value = part.split("=", 1)
|
|
parsed[key.strip()] = part_value.strip()
|
|
|
|
return parsed
|
|
|
|
@staticmethod
|
|
def _extract_proxmox_mac(parsed):
|
|
for key in ("virtio", "e1000", "e1000e", "rtl8139", "vmxnet3", "hwaddr"):
|
|
if parsed.get(key):
|
|
return parsed[key]
|
|
return None
|
|
|
|
@staticmethod
|
|
def _parse_size_mb(value):
|
|
if value in (None, ""):
|
|
return 0
|
|
|
|
match = re.match(r"^\s*([0-9.]+)\s*([KMGTPE]?)(?:i?B)?\s*$", str(value), re.IGNORECASE)
|
|
if not match:
|
|
return 0
|
|
|
|
amount = float(match.group(1))
|
|
unit = match.group(2).upper()
|
|
factors = {
|
|
"": 1,
|
|
"K": 1 / 1024,
|
|
"M": 1,
|
|
"G": 1024,
|
|
"T": 1024 * 1024,
|
|
"P": 1024 * 1024 * 1024,
|
|
"E": 1024 * 1024 * 1024 * 1024,
|
|
}
|
|
|
|
if not unit and amount > 1024 * 1024:
|
|
return round(amount / 1024 / 1024)
|
|
|
|
return round(amount * factors.get(unit, 1))
|
|
|
|
@staticmethod
|
|
def _normalize_mac(mac_address):
|
|
if not mac_address:
|
|
return None
|
|
|
|
value = str(mac_address).strip().replace("-", ":").lower()
|
|
parts = value.split(":")
|
|
if len(parts) != 6:
|
|
return None
|
|
|
|
try:
|
|
return ":".join(f"{int(part, 16):02x}" for part in parts)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
class VMwareImporter:
|
|
def __init__(self, endpoint, logger):
|
|
self.endpoint = endpoint
|
|
self.logger = logger
|
|
self.include_pattern = re.compile(endpoint.include_name_regex) if endpoint.include_name_regex else None
|
|
self.exclude_pattern = re.compile(endpoint.exclude_name_regex) if endpoint.exclude_name_regex else None
|
|
self.provider_name = "Proxmox" if endpoint.provider == EndpointProviderChoices.PROVIDER_PROXMOX else "VMware"
|
|
self.interface_content_type = None
|
|
self.mac_address_content_type = None
|
|
|
|
def sync(self):
|
|
result = SyncResult()
|
|
platforms = list(Platform.objects.all())
|
|
self.interface_content_type = ContentType.objects.get_for_model(VMInterface)
|
|
self.mac_address_content_type = ContentType.objects.get_for_model(VMInterface)
|
|
client_class = ProxmoxClient if self.endpoint.provider == EndpointProviderChoices.PROVIDER_PROXMOX else VMwareClient
|
|
|
|
with client_class(self.endpoint) as client:
|
|
for vm_data in client.iter_virtual_machines():
|
|
result.seen += 1
|
|
|
|
if not self._should_sync_vm(vm_data):
|
|
result.skipped += 1
|
|
continue
|
|
|
|
try:
|
|
with transaction.atomic():
|
|
self._sync_vm(vm_data, platforms, result)
|
|
result.synced += 1
|
|
except Exception as exc: # pragma: no cover - needs NetBox integration test
|
|
result.errors += 1
|
|
self.logger.error("Failed to synchronize VM %s: %s", vm_data.name, exc)
|
|
|
|
return result
|
|
|
|
def _should_sync_vm(self, vm_data):
|
|
if self.include_pattern and not self.include_pattern.search(vm_data.name):
|
|
return False
|
|
if self.exclude_pattern and self.exclude_pattern.search(vm_data.name):
|
|
return False
|
|
if vm_data.status == "offline" and not self.endpoint.sync_powered_off:
|
|
return False
|
|
return True
|
|
|
|
def _sync_vm(self, vm_data, platforms, result):
|
|
nb_vm = VirtualMachine.objects.filter(
|
|
name=vm_data.name,
|
|
cluster=self.endpoint.cluster,
|
|
).first()
|
|
|
|
created = nb_vm is None
|
|
if created:
|
|
nb_vm = VirtualMachine(
|
|
name=vm_data.name,
|
|
cluster=self.endpoint.cluster,
|
|
)
|
|
elif not self.endpoint.update_existing:
|
|
result.skipped += 1
|
|
return
|
|
|
|
nb_vm.status = vm_data.status
|
|
nb_vm.vcpus = vm_data.vcpus
|
|
nb_vm.memory = vm_data.memory_mb
|
|
nb_vm.tenant = self.endpoint.tenant
|
|
nb_vm.platform = self._match_platform(vm_data.guest_os, platforms)
|
|
|
|
if created or not nb_vm.virtualdisks.exists():
|
|
nb_vm.disk = vm_data.disk_mb
|
|
|
|
nb_vm.full_clean()
|
|
nb_vm.save()
|
|
|
|
if created:
|
|
result.created += 1
|
|
self.logger.info("Created VM %s", vm_data.name)
|
|
else:
|
|
result.updated += 1
|
|
self.logger.info("Updated VM %s", vm_data.name)
|
|
|
|
self._sync_virtual_disks(nb_vm, vm_data, result)
|
|
|
|
if self.endpoint.sync_interfaces:
|
|
primary_ipv4, primary_ipv6 = self._sync_interfaces(nb_vm, vm_data, result)
|
|
self._set_primary_ips(nb_vm, primary_ipv4, primary_ipv6)
|
|
|
|
@staticmethod
|
|
def _match_platform(guest_os, platforms):
|
|
if not guest_os:
|
|
return None
|
|
|
|
guest_os_lower = guest_os.lower()
|
|
for platform in platforms:
|
|
if platform.name.lower() in guest_os_lower:
|
|
return platform
|
|
|
|
return None
|
|
|
|
def _sync_virtual_disks(self, nb_vm, vm_data, result):
|
|
if not vm_data.disks:
|
|
return
|
|
|
|
synced_names = set()
|
|
|
|
for disk_data in vm_data.disks:
|
|
synced_names.add(disk_data.name)
|
|
virtual_disk = VirtualDisk.objects.filter(
|
|
virtual_machine=nb_vm,
|
|
name=disk_data.name,
|
|
).first()
|
|
created = virtual_disk is None
|
|
|
|
if created:
|
|
virtual_disk = VirtualDisk(
|
|
virtual_machine=nb_vm,
|
|
name=disk_data.name,
|
|
)
|
|
|
|
virtual_disk.size = disk_data.size_mb
|
|
virtual_disk.description = disk_data.description
|
|
virtual_disk.full_clean()
|
|
virtual_disk.save()
|
|
|
|
if created:
|
|
result.virtual_disks_created += 1
|
|
self.logger.info("Created virtual disk %s on %s", disk_data.name, nb_vm.name)
|
|
else:
|
|
result.virtual_disks_updated += 1
|
|
self.logger.info("Updated virtual disk %s on %s", disk_data.name, nb_vm.name)
|
|
|
|
stale_count = 0
|
|
for description_prefix in (f"{self.provider_name}:",):
|
|
stale_disks = VirtualDisk.objects.filter(
|
|
virtual_machine=nb_vm,
|
|
description__startswith=description_prefix,
|
|
).exclude(name__in=synced_names)
|
|
stale_count += stale_disks.count()
|
|
stale_disks.delete()
|
|
|
|
if stale_count:
|
|
result.virtual_disks_deleted += stale_count
|
|
self.logger.info("Deleted %s stale %s virtual disk(s) from %s", stale_count, self.provider_name, nb_vm.name)
|
|
|
|
disk_total = nb_vm.virtualdisks.aggregate(total=Sum("size", default=0))["total"] or vm_data.disk_mb
|
|
if nb_vm.disk != disk_total:
|
|
nb_vm.disk = disk_total
|
|
nb_vm.full_clean()
|
|
nb_vm.save(update_fields=("disk",))
|
|
self.logger.info("Updated aggregate disk size for %s to %s MB", nb_vm.name, disk_total)
|
|
|
|
def _sync_interfaces(self, nb_vm, vm_data, result):
|
|
primary_ipv4 = None
|
|
primary_ipv6 = None
|
|
|
|
for interface_data in vm_data.interfaces:
|
|
vm_interface = VMInterface.objects.filter(
|
|
virtual_machine=nb_vm,
|
|
name=interface_data.name,
|
|
).first()
|
|
created = vm_interface is None
|
|
|
|
if created:
|
|
vm_interface = VMInterface(
|
|
virtual_machine=nb_vm,
|
|
name=interface_data.name,
|
|
)
|
|
|
|
vm_interface.enabled = True
|
|
vm_interface.full_clean()
|
|
vm_interface.save()
|
|
|
|
if created:
|
|
result.interfaces_created += 1
|
|
self.logger.info("Created interface %s on %s", interface_data.name, nb_vm.name)
|
|
else:
|
|
result.interfaces_updated += 1
|
|
self.logger.info("Updated interface %s on %s", interface_data.name, nb_vm.name)
|
|
|
|
self._sync_mac_address(vm_interface, interface_data.mac_address, result)
|
|
|
|
if not self.endpoint.sync_ip_addresses:
|
|
continue
|
|
|
|
for raw_ip in interface_data.ip_addresses:
|
|
normalized = self._normalize_ip_address(raw_ip)
|
|
if normalized is None:
|
|
continue
|
|
|
|
address, family = normalized
|
|
ip_obj = self._sync_ip_address(address, vm_interface, result)
|
|
if ip_obj is None:
|
|
continue
|
|
|
|
if family == 4 and primary_ipv4 is None:
|
|
primary_ipv4 = ip_obj
|
|
if family == 6 and primary_ipv6 is None:
|
|
primary_ipv6 = ip_obj
|
|
|
|
return primary_ipv4, primary_ipv6
|
|
|
|
def _sync_mac_address(self, vm_interface, mac_address, result):
|
|
normalized_mac = self._normalize_mac_address(mac_address)
|
|
if normalized_mac is None:
|
|
return None
|
|
|
|
mac_obj = MACAddress.objects.filter(
|
|
assigned_object_type=self.mac_address_content_type,
|
|
assigned_object_id=vm_interface.pk,
|
|
mac_address=normalized_mac,
|
|
).first()
|
|
created = mac_obj is None
|
|
|
|
if created:
|
|
mac_obj = MACAddress(
|
|
mac_address=normalized_mac,
|
|
assigned_object=vm_interface,
|
|
)
|
|
|
|
mac_obj.description = f"{self.provider_name} reported MAC address"
|
|
mac_obj.full_clean()
|
|
mac_obj.save()
|
|
|
|
if created:
|
|
result.mac_addresses_created += 1
|
|
self.logger.info("Created MAC address %s on interface %s", normalized_mac, vm_interface.name)
|
|
else:
|
|
result.mac_addresses_updated += 1
|
|
self.logger.info("Updated MAC address %s on interface %s", normalized_mac, vm_interface.name)
|
|
|
|
if vm_interface.primary_mac_address_id != mac_obj.pk:
|
|
vm_interface.primary_mac_address = mac_obj
|
|
vm_interface.full_clean()
|
|
vm_interface.save(update_fields=("primary_mac_address",))
|
|
self.logger.info("Set primary MAC address %s on interface %s", normalized_mac, vm_interface.name)
|
|
|
|
stale_macs = MACAddress.objects.filter(
|
|
assigned_object_type=self.mac_address_content_type,
|
|
assigned_object_id=vm_interface.pk,
|
|
description=f"{self.provider_name} reported MAC address",
|
|
).exclude(pk=mac_obj.pk)
|
|
stale_count = stale_macs.count()
|
|
if stale_count:
|
|
stale_macs.delete()
|
|
result.mac_addresses_deleted += stale_count
|
|
self.logger.info("Deleted %s stale %s MAC address(es) from interface %s", stale_count, self.provider_name, vm_interface.name)
|
|
|
|
return mac_obj
|
|
|
|
@staticmethod
|
|
def _normalize_mac_address(mac_address):
|
|
if not mac_address:
|
|
return None
|
|
|
|
value = str(mac_address).strip().replace("-", ":").lower()
|
|
parts = value.split(":")
|
|
if len(parts) != 6:
|
|
return None
|
|
|
|
try:
|
|
return ":".join(f"{int(part, 16):02x}" for part in parts)
|
|
except ValueError:
|
|
return None
|
|
|
|
def _normalize_ip_address(self, raw_ip):
|
|
if not raw_ip:
|
|
return None
|
|
|
|
raw_ip = str(raw_ip).split("%", 1)[0]
|
|
|
|
try:
|
|
if "/" in raw_ip:
|
|
ip_interface = ipaddress.ip_interface(raw_ip)
|
|
ip_obj = ip_interface.ip
|
|
prefix_length = ip_interface.network.prefixlen
|
|
else:
|
|
ip_obj = ipaddress.ip_address(raw_ip)
|
|
prefix_length = (
|
|
self.endpoint.default_ipv4_prefix_length
|
|
if ip_obj.version == 4
|
|
else self.endpoint.default_ipv6_prefix_length
|
|
)
|
|
except ValueError:
|
|
return None
|
|
|
|
if ip_obj.is_loopback or ip_obj.is_link_local or ip_obj.is_unspecified or ip_obj.is_multicast:
|
|
return None
|
|
|
|
return f"{ip_obj.compressed}/{prefix_length}", ip_obj.version
|
|
|
|
def _sync_ip_address(self, address, vm_interface, result):
|
|
existing_ip = IPAddress.objects.filter(address=address).first()
|
|
|
|
if existing_ip and not self._may_update_ip(existing_ip, vm_interface):
|
|
result.ip_conflicts += 1
|
|
self.logger.warning(
|
|
"Skipped IP %s because it belongs to another tenant or object.",
|
|
address,
|
|
)
|
|
return None
|
|
|
|
created = existing_ip is None
|
|
ip_obj = existing_ip or IPAddress(address=address)
|
|
ip_obj.status = "active"
|
|
ip_obj.assigned_object = vm_interface
|
|
|
|
if hasattr(ip_obj, "tenant"):
|
|
ip_obj.tenant = self.endpoint.tenant
|
|
|
|
ip_obj.full_clean()
|
|
ip_obj.save()
|
|
|
|
if created:
|
|
result.ip_addresses_created += 1
|
|
self.logger.info("Created IP %s", address)
|
|
else:
|
|
result.ip_addresses_updated += 1
|
|
self.logger.info("Updated IP %s", address)
|
|
|
|
return ip_obj
|
|
|
|
def _may_update_ip(self, ip_obj, vm_interface):
|
|
assigned_to_this_interface = (
|
|
ip_obj.assigned_object_type_id == self.interface_content_type.id
|
|
and ip_obj.assigned_object_id == vm_interface.pk
|
|
)
|
|
|
|
if assigned_to_this_interface:
|
|
return True
|
|
|
|
ip_tenant_id = getattr(ip_obj, "tenant_id", None)
|
|
if self.endpoint.tenant_id and ip_tenant_id and ip_tenant_id != self.endpoint.tenant_id:
|
|
return False
|
|
|
|
if ip_obj.assigned_object_id and not assigned_to_this_interface:
|
|
return False
|
|
|
|
return True
|
|
|
|
def _set_primary_ips(self, nb_vm, primary_ipv4, primary_ipv6):
|
|
if not self.endpoint.sync_primary_ips:
|
|
return
|
|
|
|
update_fields = []
|
|
if primary_ipv4 is not None:
|
|
nb_vm.primary_ip4 = primary_ipv4
|
|
update_fields.append("primary_ip4")
|
|
if primary_ipv6 is not None:
|
|
nb_vm.primary_ip6 = primary_ipv6
|
|
update_fields.append("primary_ip6")
|
|
|
|
if update_fields:
|
|
nb_vm.full_clean()
|
|
nb_vm.save(update_fields=update_fields)
|
|
self.logger.info("Updated primary IPs for %s", nb_vm.name)
|