Use proxmox sdk for Proxmox sync

This commit is contained in:
2026-07-20 13:01:37 +02:00
parent a361b6993d
commit cb712a31cd
9 changed files with 276 additions and 37 deletions
+1
View File
@@ -0,0 +1 @@
netbox_vmware_importer/migrations
+1
View File
@@ -17,3 +17,4 @@
| 0.2.3 | 4.4.0 | 4.6.x | | 0.2.3 | 4.4.0 | 4.6.x |
| 0.2.4 | 4.4.0 | 4.6.x | | 0.2.4 | 4.4.0 | 4.6.x |
| 0.2.5 | 4.4.0 | 4.6.x | | 0.2.5 | 4.4.0 | 4.6.x |
| 0.2.6 | 4.4.0 | 4.6.x |
+5 -3
View File
@@ -25,7 +25,7 @@ Im Python-Environment der NetBox-Installation:
pip install git+https://git.mrblake.cc/MrBlake/NetBox-VM-Import.git pip install git+https://git.mrblake.cc/MrBlake/NetBox-VM-Import.git
``` ```
NetBox selbst ist keine `pip`-Dependency des Plugins. Das Plugin wird im vorhandenen NetBox-Virtualenv installiert und bringt nur externe Bibliotheken wie `pyvmomi` und `cryptography` mit. NetBox selbst ist keine `pip`-Dependency des Plugins. Das Plugin wird im vorhandenen NetBox-Virtualenv installiert und bringt nur externe Bibliotheken wie `pyvmomi`, `cryptography`, `requests` und `proxmox-sdk` mit.
In `configuration.py` aktivieren: In `configuration.py` aktivieren:
@@ -54,12 +54,14 @@ sudo systemctl restart netbox netbox-rq
Bei selbstsignierten Proxmox-Zertifikaten `Validate SSL` deaktiviert lassen. Wenn Proxmox langsam antwortet, kann `API timeout` am Endpoint erhoeht werden. Bei selbstsignierten Proxmox-Zertifikaten `Validate SSL` deaktiviert lassen. Wenn Proxmox langsam antwortet, kann `API timeout` am Endpoint erhoeht werden.
Der Proxmox-Client ignoriert Proxy-Umgebungsvariablen des NetBox-Dienstes, damit interne Proxmox-Adressen nicht versehentlich ueber einen HTTPS-Proxy laufen. Der Proxmox-Client nutzt bevorzugt `proxmox-sdk` wie `netbox-proxbox`/`proxbox-api`. Falls dieses Paket nicht installiert ist, faellt er auf den bisherigen `requests`-Client zurueck. Proxy-Umgebungsvariablen des NetBox-Dienstes werden im Fallback ignoriert, damit interne Proxmox-Adressen nicht versehentlich ueber einen HTTPS-Proxy laufen.
Der unauthentifizierte Proxmox-`version`-Probe ist nur diagnostisch. Wenn er haengt, versucht das Plugin den eigentlichen Login trotzdem. Im `requests`-Fallback ist der unauthentifizierte Proxmox-`version`-Probe nur diagnostisch. Wenn er haengt, versucht das Plugin den eigentlichen Login trotzdem. Im bevorzugten SDK-Pfad wird `version` als erste SDK-Anfrage genutzt und authentifiziert dabei automatisch.
Proxmox-API-Verbindungen nutzen TLS 1.2-Kompatibilitaetsmodus, weil einige Firewalls oder TLS-Inspektionspfade moderne TLS-1.3-ClientHellos blockieren. Proxmox-API-Verbindungen nutzen TLS 1.2-Kompatibilitaetsmodus, weil einige Firewalls oder TLS-Inspektionspfade moderne TLS-1.3-ClientHellos blockieren.
Bei Proxmox koennen `Connect timeout`, `API timeout`, `GET retries` und `Retry backoff` pro Endpoint gesetzt werden. Das entspricht dem Ansatz von Proxbox, Verbindungs- und Retry-Parameter pro Proxmox-Ziel zu speichern.
Wenn Passwort-Login auf `access/ticket` haengt, zuerst den Realm im Benutzernamen pruefen (`root@pam`, `user@pve`, `user@ldaprealm`). Fuer produktive Imports ist ein Proxmox API-Token meistens stabiler als Passwort-Login. Wenn Passwort-Login auf `access/ticket` haengt, zuerst den Realm im Benutzernamen pruefen (`root@pam`, `user@pve`, `user@ldaprealm`). Fuer produktive Imports ist ein Proxmox API-Token meistens stabiler als Passwort-Login.
Wenn `Sync interval minutes` gesetzt ist, prueft ein Systemjob alle fuenf Minuten, welche Endpoints faellig sind, und stellt die eigentlichen Sync-Jobs in die Queue. Wenn `Sync interval minutes` gesetzt ist, prueft ein Systemjob alle fuenf Minuten, welche Endpoints faellig sind, und stellt die eigentlichen Sync-Jobs in die Queue.
+1 -1
View File
@@ -5,7 +5,7 @@ class VMwareImporterConfig(PluginConfig):
name = "netbox_vmware_importer" name = "netbox_vmware_importer"
verbose_name = "Virtualization Importer" verbose_name = "Virtualization Importer"
description = "Synchronize VMware vSphere and Proxmox VE virtual machines into NetBox." description = "Synchronize VMware vSphere and Proxmox VE virtual machines into NetBox."
version = "0.2.5" version = "0.2.6"
author = "Internal NetBox Team" author = "Internal NetBox Team"
base_url = "vmware-importer" base_url = "vmware-importer"
min_version = "4.4.0" min_version = "4.4.0"
+6
View File
@@ -56,6 +56,9 @@ class VCenterEndpointForm(NetBoxModelForm):
"password", "password",
"validate_ssl", "validate_ssl",
"request_timeout_seconds", "request_timeout_seconds",
"connect_timeout_seconds",
"max_retries",
"retry_backoff_seconds",
name=_("Connection"), name=_("Connection"),
), ),
FieldSet("tenant", "site", "cluster", name=_("NetBox target")), FieldSet("tenant", "site", "cluster", name=_("NetBox target")),
@@ -89,6 +92,9 @@ class VCenterEndpointForm(NetBoxModelForm):
"password", "password",
"validate_ssl", "validate_ssl",
"request_timeout_seconds", "request_timeout_seconds",
"connect_timeout_seconds",
"max_retries",
"retry_backoff_seconds",
"tenant", "tenant",
"site", "site",
"cluster", "cluster",
+21
View File
@@ -1,4 +1,5 @@
import re import re
from decimal import Decimal
from datetime import timedelta from datetime import timedelta
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@@ -65,6 +66,23 @@ class VCenterEndpoint(JobsMixin, NetBoxModel):
validators=[MinValueValidator(5), MaxValueValidator(600)], validators=[MinValueValidator(5), MaxValueValidator(600)],
help_text=_("HTTP/API read timeout in seconds."), help_text=_("HTTP/API read timeout in seconds."),
) )
connect_timeout_seconds = models.PositiveIntegerField(
default=10,
validators=[MinValueValidator(1), MaxValueValidator(120)],
help_text=_("TCP/TLS connection timeout in seconds."),
)
max_retries = models.PositiveSmallIntegerField(
default=0,
validators=[MinValueValidator(0), MaxValueValidator(10)],
help_text=_("Retries for safe Proxmox GET requests when the endpoint returns transient errors."),
)
retry_backoff_seconds = models.DecimalField(
max_digits=5,
decimal_places=2,
default=Decimal("0.50"),
validators=[MinValueValidator(Decimal("0.00")), MaxValueValidator(Decimal("60.00"))],
help_text=_("Exponential retry backoff base delay in seconds."),
)
tenant = models.ForeignKey( tenant = models.ForeignKey(
to="tenancy.Tenant", to="tenancy.Tenant",
on_delete=models.PROTECT, on_delete=models.PROTECT,
@@ -186,6 +204,9 @@ class VCenterEndpoint(JobsMixin, NetBoxModel):
"token_name", "token_name",
"validate_ssl", "validate_ssl",
"request_timeout_seconds", "request_timeout_seconds",
"connect_timeout_seconds",
"max_retries",
"retry_backoff_seconds",
"tenant", "tenant",
"site", "site",
"cluster", "cluster",
+231 -32
View File
@@ -1,7 +1,9 @@
import ipaddress import ipaddress
import re import re
import ssl import ssl
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from urllib.parse import urlsplit
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import transaction from django.db import transaction
@@ -21,6 +23,19 @@ except ImportError: # pragma: no cover - handled at runtime inside NetBox
requests = None requests = None
InsecureRequestWarning = 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: try:
from pyVim.connect import Disconnect, SmartConnect from pyVim.connect import Disconnect, SmartConnect
from pyVmomi import vim from pyVmomi import vim
@@ -253,20 +268,114 @@ class ProxmoxClient:
DISK_KEY_PATTERN = re.compile(r"^(ide|sata|scsi|virtio)\d+$") DISK_KEY_PATTERN = re.compile(r"^(ide|sata|scsi|virtio)\d+$")
LXC_DISK_KEY_PATTERN = re.compile(r"^(rootfs|mp\d+)$") LXC_DISK_KEY_PATTERN = re.compile(r"^(rootfs|mp\d+)$")
NET_KEY_PATTERN = re.compile(r"^net\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): def __init__(self, endpoint):
self.endpoint = 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.session = None
self.base_url = f"https://{endpoint.host}:{endpoint.port}/api2/json" self.sdk = None
self.timeout = (10, endpoint.request_timeout_seconds or 120) 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_succeeded = False
self.api_probe_error = ""
def __enter__(self): def __enter__(self):
if requests is None:
raise ProxmoxConnectionError("requests is not installed in the NetBox Python environment.")
if "@" not in self.endpoint.username: if "@" not in self.endpoint.username:
raise ProxmoxConnectionError("Proxmox username must include a realm, e.g. root@pam or user@pve.") 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 = requests.Session()
self.session.trust_env = False self.session.trust_env = False
self.session.verify = self.endpoint.validate_ssl self.session.verify = self.endpoint.validate_ssl
@@ -285,17 +394,11 @@ class ProxmoxClient:
self._probe_api_availability() self._probe_api_availability()
if self.endpoint.auth_method == EndpointAuthMethodChoices.METHOD_API_TOKEN: if self.endpoint.auth_method == EndpointAuthMethodChoices.METHOD_API_TOKEN:
token_id = self.endpoint.token_name if "!" in self.endpoint.token_name else f"{self.endpoint.username}!{self.endpoint.token_name}" user, token_name, token_value = self._normalized_token_credentials()
self.session.headers.update({"Authorization": f"PVEAPIToken={token_id}={self.endpoint.password}"}) self.session.headers.update({"Authorization": f"PVEAPIToken={user}!{token_name}={token_value}"})
else: else:
self._authenticate_with_password() self._authenticate_with_password()
return self
def __exit__(self, exc_type, exc_value, traceback):
if self.session is not None:
self.session.close()
def _probe_api_availability(self): def _probe_api_availability(self):
try: try:
self._request("GET", "version", timeout=(5, 10)) self._request("GET", "version", timeout=(5, 10))
@@ -305,6 +408,45 @@ class ProxmoxClient:
self.api_probe_succeeded = False self.api_probe_succeeded = False
self.api_probe_error = str(exc) 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): def _authenticate_with_password(self):
response = self._request( response = self._request(
"POST", "POST",
@@ -331,29 +473,86 @@ class ProxmoxClient:
def _request(self, method, path, timeout=None, **kwargs): def _request(self, method, path, timeout=None, **kwargs):
url = self._url(path) url = self._url(path)
request_timeout = timeout or self.timeout 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: try:
response = self.session.request(method, url, timeout=request_timeout, **kwargs) return self.sdk(path.strip("/")).get(**(params or {}))
response.raise_for_status() except Exception as exc:
return response raise ProxmoxConnectionError(self._describe_sdk_error("GET", path, exc)) from exc
except requests.ConnectTimeout as exc:
raise ProxmoxConnectionError( def _describe_sdk_error(self, method, path, exc):
f"Proxmox API {method} {path} connect timed out after {request_timeout[0]} seconds." if SDKTimeoutError is not None and isinstance(exc, SDKTimeoutError):
) from exc return f"Proxmox SDK {method} {path} timed out after {self.read_timeout} seconds."
except requests.ReadTimeout as exc:
detail = f"Proxmox API {method} {path} read timed out after {request_timeout[1]} seconds." if SDKConnectionError is not None and isinstance(exc, SDKConnectionError):
if path == "access/ticket" and self.api_probe_succeeded: detail = str(exc).strip() or "connection failed"
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." return f"Proxmox SDK {method} {path} connection failed: {detail}"
elif getattr(self, "api_probe_error", "") and path != "version":
detail += f" Earlier unauthenticated version probe also failed: {self.api_probe_error}" if SDKResourceException is not None and isinstance(exc, SDKResourceException):
raise ProxmoxConnectionError(detail) from exc status_code = getattr(exc, "status_code", None)
except requests.exceptions.SSLError as exc: status_message = getattr(exc, "status_message", "") or ""
raise ProxmoxConnectionError( content = (getattr(exc, "content", "") or "").strip()
f"Proxmox API {method} {path} TLS validation failed. Disable Validate SSL for self-signed certificates or install the CA certificate. Details: {exc}" errors = getattr(exc, "errors", None)
) from exc parts = []
except requests.RequestException as exc: if status_code:
raise ProxmoxConnectionError(f"Proxmox API {method} {path} failed: {exc}") from exc 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): def _get(self, path, params=None):
if self.uses_sdk:
return self._sdk_get(path, params=params)
response = self._request("GET", path, params=params) response = self._request("GET", path, params=params)
return response.json().get("data") return response.json().get("data")
@@ -44,6 +44,14 @@
<th scope="row">API timeout</th> <th scope="row">API timeout</th>
<td>{{ object.request_timeout_seconds }} seconds</td> <td>{{ object.request_timeout_seconds }} seconds</td>
</tr> </tr>
<tr>
<th scope="row">Connect timeout</th>
<td>{{ object.connect_timeout_seconds }} seconds</td>
</tr>
<tr>
<th scope="row">GET retries</th>
<td>{{ object.max_retries }} retries, {{ object.retry_backoff_seconds }}s backoff</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
+2 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "netbox-vmware-importer" name = "netbox-vmware-importer"
version = "0.2.5" version = "0.2.6"
description = "NetBox plugin to synchronize VMware vSphere and Proxmox VE virtual machines into NetBox." description = "NetBox plugin to synchronize VMware vSphere and Proxmox VE virtual machines into NetBox."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -23,6 +23,7 @@ dependencies = [
"pyvmomi>=8.0.3", "pyvmomi>=8.0.3",
"cryptography>=42.0", "cryptography>=42.0",
"requests>=2.32", "requests>=2.32",
"proxmox-sdk==0.0.12",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]