added proxmox power button plugin 0.1

This commit is contained in:
2026-08-05 04:46:26 +02:00
parent 28fa9afa28
commit c8f5baedde
15 changed files with 817 additions and 1 deletions

View File

@@ -0,0 +1,50 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
from netbox.plugins import PluginConfig
class ProxmoxPowerButtonConfig(PluginConfig):
name = "proxmox_power_button"
verbose_name = "Proxmox Power Button"
description = "Start/stop/reboot Proxmox VMs from the NetBox VM detail page"
version = "0.1.0"
author = "Bryan Pedini"
base_url = "proxmox-power-button"
# The data migration pins extras.0134_owner / virtualization.0052_gfk_indexes,
# which first exist in NetBox 4.5.0 — on anything older `migrate` would die
# with NodeNotFoundError, so don't claim compatibility we can't honour.
min_version = "4.5.0"
# Optional behaviour, overridable via PLUGINS_CONFIG["proxmox_power_button"]:
# verify_ssl : verify the Proxmox TLS cert (default False)
# stop_mode : "shutdown" (graceful ACPI, default) or "stop" (hard kill)
# reboot_mode : "reboot" (graceful, default) or "reset" (hard)
default_settings = {
"verify_ssl": False,
"stop_mode": "shutdown",
"reboot_mode": "reboot",
}
def ready(self):
super().ready()
# Register the per-cluster VMID uniqueness validator (post_clean).
from . import signals # noqa: F401
# Ensure power-operation audit lines reach stdout (container logs) even
# when the deployment ships the default, mostly-silent logging config.
import logging
import sys
log = logging.getLogger("proxmox_power_button")
if not any(getattr(h, "_ppb_handler", False) for h in log.handlers):
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(
logging.Formatter("%(levelname)s %(asctime)s proxmox_power_button %(message)s")
)
handler._ppb_handler = True
log.addHandler(handler)
log.setLevel(logging.INFO)
log.propagate = False
config = ProxmoxPowerButtonConfig

View File

@@ -0,0 +1,97 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
"""
Provision the custom fields this plugin relies on.
A real (data) migration — not a post_migrate hook — because the netbox-docker
entrypoint only runs `migrate` when `migrate --check` reports something pending.
A pending migration is therefore what guarantees provisioning on first boot.
Creates:
* virtualization.VirtualMachine -> "vmid" (integer, required, min 100)
* virtualization.Cluster -> "endpoint" (text)
* virtualization.Cluster -> "token" (text)
Each field carries a description, rendered as help text in the UI so the
expected format is obvious without reading the source.
VMID per-cluster uniqueness is enforced separately in signals.py (post_clean).
"""
from django.db import migrations
FIELD_NAMES = ["vmid", "endpoint", "token"]
def create_custom_fields(apps, schema_editor):
# Use the real models (not historical): by the time this runs, extras and
# virtualization are fully migrated, so CustomField.object_types exists.
from django.contrib.contenttypes.models import ContentType
from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField
vm_ct = ContentType.objects.get(app_label="virtualization", model="virtualmachine")
cluster_ct = ContentType.objects.get(app_label="virtualization", model="cluster")
vmid, _ = CustomField.objects.get_or_create(
name="vmid",
defaults=dict(
type=CustomFieldTypeChoices.TYPE_INTEGER,
label="VMID",
required=True,
validation_minimum=100, # Proxmox VMIDs start at 100
group_name="Proxmox",
description=(
"Proxmox VM ID (integer, min 100). "
"Must be unique within the VM's cluster."
),
),
)
vmid.object_types.set([vm_ct])
cluster_fields = [
(
"endpoint",
"Proxmox Endpoint",
"Proxmox host or URL, e.g. pve.example.com:8006 or "
"https://pve.example.com:8006",
),
(
"token",
"Proxmox API Token",
"Proxmox API token as user@realm!tokenid=secret "
"(e.g. root@pam!netbox=6f31a4c2-9d1e-4b7a-8c23-1f5e0a9b2d34)",
),
]
for name, label, description in cluster_fields:
cf, _ = CustomField.objects.get_or_create(
name=name,
defaults=dict(
type=CustomFieldTypeChoices.TYPE_TEXT,
label=label,
group_name="Proxmox",
description=description,
),
)
cf.object_types.set([cluster_ct])
def remove_custom_fields(apps, schema_editor):
from extras.models import CustomField
CustomField.objects.filter(name__in=FIELD_NAMES).delete()
class Migration(migrations.Migration):
initial = True
# Pin to this NetBox version's migration graph so CustomField.object_types is
# available. Bump these (and PluginConfig.min_version) when targeting a
# different NetBox release.
dependencies = [
("extras", "0134_owner"),
("virtualization", "0052_gfk_indexes"),
]
operations = [
migrations.RunPython(create_custom_fields, remove_custom_fields),
]

View File

@@ -0,0 +1,2 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini

View File

@@ -0,0 +1,5 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# This plugin defines no models of its own — it extends core VirtualMachine and
# Cluster via custom fields (created in migrations/0001_initial.py) and enforces
# per-cluster VMID uniqueness via a signal (signals.py).

View File

@@ -0,0 +1,151 @@
# 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

View File

@@ -0,0 +1,40 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
"""
Signal receivers for the proxmox_power_button plugin.
Enforce that a VMID is unique *within its cluster*. This runs on post_clean —
fired during full_clean() — so it surfaces as a normal validation error in both
the UI forms and the REST API, covering VM creation, VMID edits, and moving a VM
to a different cluster.
(The custom fields themselves are provisioned by migrations/0001_initial.py.)
"""
from django.core.exceptions import ValidationError
from django.dispatch import receiver
from netbox.signals import post_clean
from virtualization.models import VirtualMachine
VMID_FIELD = "vmid"
@receiver(post_clean, sender=VirtualMachine)
def enforce_vmid_unique_per_cluster(sender, instance, **kwargs):
data = instance.custom_field_data or {}
vmid = data.get(VMID_FIELD)
# Nothing to check until both a VMID and a cluster are set.
if vmid is None or instance.cluster_id is None:
return
duplicate = (
VirtualMachine.objects
.filter(cluster_id=instance.cluster_id, custom_field_data__vmid=vmid)
.exclude(pk=instance.pk or 0)
.first()
)
if duplicate is not None:
raise ValidationError(
f"VMID {vmid} is already assigned to VM '{duplicate.name}' in "
f"cluster '{instance.cluster}'. VMIDs must be unique within a cluster."
)

View File

@@ -0,0 +1,21 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
from netbox.plugins import PluginTemplateExtension
class VMPowerButtons(PluginTemplateExtension):
"""
Inject start/stop/reboot buttons into the VM detail page button bar.
Rendered via {% plugin_buttons %} in generic/object.html, which sits in the
`controls` block *before* `extra_controls` (where "Add Components" lives) —
so these land to the left of it, as required.
"""
models = ['virtualization.virtualmachine']
def buttons(self):
return self.render('proxmox_power_button/vm_power_buttons.html')
template_extensions = [VMPowerButtons]

View File

@@ -0,0 +1,24 @@
{# SPDX-License-Identifier: GPL-2.0-or-later — Copyright (C) 2026 Bryan Joshua Pedini #}
{% if object.status == 'active' %}
{# Powered on: reboot (left) + stop. #}
<form class="d-inline" method="post" action="{% url 'plugins:proxmox_power_button:power' pk=object.pk action='reboot' %}">
{% csrf_token %}
<button type="submit" class="btn btn-orange" title="Reboot VM">
<i class="mdi mdi-sync" aria-hidden="true"></i> Reboot
</button>
</form>
<form class="d-inline" method="post" action="{% url 'plugins:proxmox_power_button:power' pk=object.pk action='stop' %}">
{% csrf_token %}
<button type="submit" class="btn btn-red" title="Stop VM">
<i class="mdi mdi-stop" aria-hidden="true"></i> Stop
</button>
</form>
{% else %}
{# Powered off: start only. #}
<form class="d-inline" method="post" action="{% url 'plugins:proxmox_power_button:power' pk=object.pk action='start' %}">
{% csrf_token %}
<button type="submit" class="btn btn-green" title="Start VM">
<i class="mdi mdi-play" aria-hidden="true"></i> Start
</button>
</form>
{% endif %}

View File

@@ -0,0 +1,13 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
from django.urls import path
from . import views
urlpatterns = [
path(
"virtual-machines/<int:pk>/power/<str:action>/",
views.VMPowerActionView.as_view(),
name="power",
),
]

View File

@@ -0,0 +1,113 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
import logging
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.shortcuts import get_object_or_404, redirect
from django.views import View
from core.choices import ObjectChangeActionChoices
from virtualization.choices import VirtualMachineStatusChoices
from virtualization.models import VirtualMachine
from .proxmox import ProxmoxError, power_action
logger = logging.getLogger("proxmox_power_button")
VALID_ACTIONS = {"start", "stop", "reboot"}
def _log_changelog_only(request, vm, message):
"""
Record a changelog entry for an operation that changes no field.
Used for reboot: the VM stays powered on, so there is nothing to save. We
build the ObjectChange by hand (pre == post, no field diff) rather than
touching the object, and attribute it to the current request/user exactly
like ChangeLoggingMiddleware would.
"""
vm.snapshot()
objectchange = vm.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
objectchange.message = message
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
def _save_with_changelog(request, vm, status, message):
"""
Apply a status change; the post_save signal writes a single changelog
entry carrying our message.
"""
vm.snapshot()
vm._changelog_message = message
vm.status = status
vm.save()
class VMPowerActionView(PermissionRequiredMixin, View):
"""
POST-only action endpoint (Post/Redirect/Get) — renders no page.
Performs the Proxmox power action, records exactly one changelog entry per
state change, and logs every outcome (including failures).
"""
permission_required = "virtualization.change_virtualmachine"
def get(self, request, pk, action):
# Not a real page — just bounce back to the VM.
return redirect("virtualization:virtualmachine", pk=pk)
def post(self, request, pk, action):
vm = get_object_or_404(VirtualMachine, pk=pk)
if action not in VALID_ACTIONS:
logger.error("user=%s vm=%s: unknown power action '%s'", request.user, vm.name, action)
messages.error(request, f"Unknown power action '{action}'.")
return redirect("virtualization:virtualmachine", pk=vm.pk)
try:
result = power_action(vm, action)
except ProxmoxError as exc:
logger.error("user=%s vm=%s: %s failed: %s", request.user, vm.name, action, exc)
messages.error(request, f"Proxmox action failed: {exc}")
return redirect("virtualization:virtualmachine", pk=vm.pk)
if action == "start":
if result == "running":
_save_with_changelog(
request, vm,
VirtualMachineStatusChoices.STATUS_ACTIVE,
"Powered on via Proxmox (confirmed running)",
)
logger.info("user=%s vm=%s: powered on (confirmed running)", request.user, vm.name)
messages.success(request, f"{vm.name} is powered on (confirmed running).")
else:
# Command accepted but the VM is not running yet: no state
# change, so no changelog entry — log only.
state = result or "unknown"
logger.warning(
"user=%s vm=%s: start sent but not confirmed running (state=%s)",
request.user, vm.name, state,
)
messages.warning(
request,
f"Start sent to {vm.name}, but it is not reporting 'running' yet "
f"({state}); status left unchanged.",
)
elif action == "stop":
_save_with_changelog(
request, vm,
VirtualMachineStatusChoices.STATUS_OFFLINE,
"Powered off via Proxmox (shutdown sent)",
)
logger.info("user=%s vm=%s: stop (shutdown) sent", request.user, vm.name)
messages.success(request, f"Stop sent to {vm.name}.")
else: # reboot — VM stays powered on: changelog entry, but no save
_log_changelog_only(request, vm, "Rebooted via Proxmox")
logger.info("user=%s vm=%s: reboot sent", request.user, vm.name)
messages.success(request, f"Reboot sent to {vm.name}.")
return redirect("virtualization:virtualmachine", pk=vm.pk)