You've already forked netbox-proxmox-power-button
152 lines
5.0 KiB
Python
152 lines
5.0 KiB
Python
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
# Copyright (C) 2026 Bryan Joshua Pedini
|
|
"""
|
|
Thin Proxmox VE client.
|
|
|
|
Connection details come from the VM's cluster custom fields:
|
|
* cluster.cf["endpoint"] -> "host", "host:port" or "https://host:port"
|
|
* cluster.cf["token"] -> "user@realm!tokenid=secret"
|
|
and the target VM is identified by vm.cf["vmid"].
|
|
"""
|
|
import re
|
|
import time
|
|
from urllib.parse import urlparse
|
|
|
|
from django.conf import settings
|
|
|
|
try:
|
|
from proxmoxer import ProxmoxAPI
|
|
except ImportError: # pragma: no cover - dependency missing
|
|
ProxmoxAPI = None
|
|
|
|
|
|
class ProxmoxError(Exception):
|
|
"""
|
|
Any failure resolving config or talking to Proxmox.
|
|
"""
|
|
|
|
|
|
def _plugin_settings():
|
|
return settings.PLUGINS_CONFIG.get("proxmox_power_button", {})
|
|
|
|
|
|
def _parse_endpoint(endpoint):
|
|
endpoint = (endpoint or "").strip()
|
|
if not endpoint:
|
|
raise ProxmoxError("cluster endpoint is empty")
|
|
if "://" in endpoint:
|
|
parsed = urlparse(endpoint)
|
|
return parsed.hostname, parsed.port or 8006
|
|
if ":" in endpoint:
|
|
host, _, port = endpoint.rpartition(":")
|
|
return host, int(port)
|
|
return endpoint, 8006
|
|
|
|
|
|
def _parse_token(token):
|
|
match = re.match(r"^(?P<user>[^!]+)!(?P<name>[^=]+)=(?P<value>.+)$", (token or "").strip())
|
|
if not match:
|
|
raise ProxmoxError("token must be in the form 'user@realm!tokenid=secret'")
|
|
return match.group("user"), match.group("name"), match.group("value")
|
|
|
|
|
|
def _command_for(action, cfg):
|
|
if action == "start":
|
|
return "start"
|
|
if action == "stop":
|
|
return cfg.get("stop_mode", "shutdown")
|
|
if action == "reboot":
|
|
return cfg.get("reboot_mode", "reboot")
|
|
raise ProxmoxError(f"unknown action '{action}'")
|
|
|
|
|
|
def _find_by_vmid(prox, vmid):
|
|
target = int(vmid)
|
|
visible = []
|
|
for resource in prox.cluster.resources.get(type="vm"):
|
|
rid = resource.get("vmid")
|
|
if rid is None:
|
|
continue
|
|
visible.append(int(rid))
|
|
if int(rid) == target:
|
|
return resource # has 'node' and 'type' (qemu|lxc)
|
|
|
|
if not visible:
|
|
# cluster/resources came back empty: almost always the API token has
|
|
# "Privilege Separation" enabled (default) and no permission granted,
|
|
# so it can see nothing.
|
|
raise ProxmoxError(
|
|
f"VMID {vmid} not found: this API token can see no VMs. Check the "
|
|
f"token's Privilege Separation / permissions (needs at least "
|
|
f"VM.Audit and VM.PowerMgmt on the VM, or Sys.Audit on /)."
|
|
)
|
|
raise ProxmoxError(
|
|
f"VMID {vmid} not found among {len(visible)} VM(s) visible to this token "
|
|
f"(visible VMIDs: {sorted(visible)[:30]}). Wrong cluster endpoint, or the "
|
|
f"token lacks permission on this VM."
|
|
)
|
|
|
|
|
|
def power_action(vm, action):
|
|
"""
|
|
Perform start/stop/reboot on the Proxmox VM backing this NetBox VM.
|
|
|
|
Raises ProxmoxError on any problem. For action == "start", returns the
|
|
verified power state ("running" / "stopped" / None if unconfirmed) after a
|
|
short delay; for stop/reboot returns None.
|
|
"""
|
|
if ProxmoxAPI is None:
|
|
raise ProxmoxError("the 'proxmoxer' package is not installed")
|
|
|
|
cfg = _plugin_settings()
|
|
|
|
cluster = vm.cluster
|
|
if cluster is None:
|
|
raise ProxmoxError("VM is not assigned to a cluster")
|
|
|
|
endpoint = (cluster.custom_field_data or {}).get("endpoint")
|
|
token = (cluster.custom_field_data or {}).get("token")
|
|
vmid = (vm.custom_field_data or {}).get("vmid")
|
|
|
|
if not endpoint or not token:
|
|
raise ProxmoxError(
|
|
f"cluster '{cluster}' is missing the Proxmox 'endpoint'/'token' custom fields"
|
|
)
|
|
if vmid is None:
|
|
raise ProxmoxError("VM has no VMID set")
|
|
|
|
host, port = _parse_endpoint(endpoint)
|
|
user, token_name, token_value = _parse_token(token)
|
|
command = _command_for(action, cfg)
|
|
|
|
try:
|
|
prox = ProxmoxAPI(
|
|
host,
|
|
port=port,
|
|
user=user,
|
|
token_name=token_name,
|
|
token_value=token_value,
|
|
verify_ssl=cfg.get("verify_ssl", False),
|
|
)
|
|
resource = _find_by_vmid(prox, vmid)
|
|
node = resource["node"]
|
|
vtype = resource["type"] # 'qemu' or 'lxc'
|
|
status_obj = getattr(prox.nodes(node), vtype)(vmid).status
|
|
getattr(status_obj, command).post()
|
|
except ProxmoxError:
|
|
raise
|
|
except Exception as exc: # proxmoxer / network / auth errors
|
|
raise ProxmoxError(str(exc)) from exc
|
|
|
|
# For power-on only, do a quick confirmation call so NetBox status reflects
|
|
# reality (start is fast). Reboot/shutdown take too long to confirm inline,
|
|
# so they are not verified here.
|
|
if action == "start":
|
|
time.sleep(3)
|
|
try:
|
|
current = getattr(prox.nodes(node), vtype)(vmid).status.current.get()
|
|
return current.get("status") # 'running' | 'stopped'
|
|
except Exception: # verification is best-effort; command already sent
|
|
return None
|
|
return None
|