From c8f5baeddebb38459a516e2320fbc111f6ae7467 Mon Sep 17 00:00:00 2001 From: Bryan Joshua Pedini Date: Wed, 5 Aug 2026 04:46:26 +0200 Subject: [PATCH] added proxmox power button plugin 0.1 --- .gitignore | 6 + MANIFEST.in | 3 + README.md | 139 +++++++++++++++- makefile | 96 +++++++++++ proxmox_power_button/__init__.py | 50 ++++++ .../migrations/0001_initial.py | 97 +++++++++++ proxmox_power_button/migrations/__init__.py | 2 + proxmox_power_button/models.py | 5 + proxmox_power_button/proxmox.py | 151 ++++++++++++++++++ proxmox_power_button/signals.py | 40 +++++ proxmox_power_button/template_content.py | 21 +++ .../vm_power_buttons.html | 24 +++ proxmox_power_button/urls.py | 13 ++ proxmox_power_button/views.py | 113 +++++++++++++ pyproject.toml | 58 +++++++ 15 files changed, 817 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 MANIFEST.in create mode 100644 makefile create mode 100644 proxmox_power_button/__init__.py create mode 100644 proxmox_power_button/migrations/0001_initial.py create mode 100644 proxmox_power_button/migrations/__init__.py create mode 100644 proxmox_power_button/models.py create mode 100644 proxmox_power_button/proxmox.py create mode 100644 proxmox_power_button/signals.py create mode 100644 proxmox_power_button/template_content.py create mode 100644 proxmox_power_button/templates/proxmox_power_button/vm_power_buttons.html create mode 100644 proxmox_power_button/urls.py create mode 100644 proxmox_power_button/views.py create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b846358 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +build/ +dist/ +*.egg-info/ +.venv-publish/ diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..fdbda3a --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include LICENSE +include README.md +recursive-include proxmox_power_button/templates *.html diff --git a/README.md b/README.md index c2545cf..c9a4a71 100644 --- a/README.md +++ b/README.md @@ -1 +1,138 @@ -# netbox-proxmox-power-button +# proxmox_power_button + +NetBox plugin that adds **Start / Stop / Reboot** buttons to the Virtual Machine +detail page and drives the corresponding Proxmox VE VM, reflecting the power +state back into the NetBox VM status. + +## Installation + +```bash +pip install netbox-proxmox-power-button +``` + +The distribution is `netbox-proxmox-power-button`; the importable package — and +the name that goes into NetBox's `PLUGINS` — is `proxmox_power_button`: + +```python +# configuration/plugins.py +PLUGINS = ["proxmox_power_button"] + +PLUGINS_CONFIG = { + "proxmox_power_button": { + "verify_ssl": False, + "stop_mode": "shutdown", + "reboot_mode": "reboot", + }, +} +``` + +Then run `./manage.py migrate` (creates the custom fields) and restart NetBox +and its worker. + +## Behaviour + +- Buttons live in the VM detail page's button bar, **before "Add Components"** + (injected via `{% plugin_buttons %}`, no custom page). +- **Start** — green, `mdi-play`. Shown only when the VM is *not* `active`. + On success sets NetBox status → `active`. +- **Stop** — red, `mdi-stop`. Shown only when the VM is `active`. + On success sets NetBox status → `offline`. +- **Reboot** — orange, `mdi-sync`. Shown only when the VM is `active`, to the + left of Stop. Leaves status unchanged. +- NetBox status is updated **only after** Proxmox confirms the command. If + Proxmox is unconfigured/unreachable, the action shows an error and changes + nothing (no 500). +- After a **start**, the plugin waits 3s and re-queries Proxmox; the status is + set to `active` only if the VM reports `running`. Stop/reboot are not verified + inline (they take too long to settle). + +## Auditing + +One changelog entry per **state change**, each carrying a readable message +(visible in the VM's Changelog tab and in `/core/changelog/`): + +| operation | changelog entry | object saved | +|-----------------------------|-----------------|--------------| +| start (confirmed running) | "Powered on via Proxmox (confirmed running)" | yes (status) | +| stop | "Powered off via Proxmox (shutdown sent)" | yes (status) | +| reboot | "Rebooted via Proxmox" | **no** — entry written directly | +| start sent, not yet running | none (no state change) | no | +| failure | none (no state change) | no | + +Every outcome — including the two "none" rows above — is written to the +`proxmox_power_button` logger (visible in `docker compose logs netbox`), e.g.: + +``` +INFO … proxmox_power_button user=admin vm=jellyfin: powered on (confirmed running) +ERROR … proxmox_power_button user=admin vm=jellyfin: start failed: VMID 20100 not found … +``` + +## Data model (custom fields, auto-created by migration) + +- `VirtualMachine.vmid` — integer, **required**, min 100. Unique **within a + cluster** (enforced on create, on VMID change, and when moving the VM to + another cluster — a move into a cluster that already has that VMID is rejected). +- `Cluster.endpoint` — text: `host`, `host:port`, or `https://host:port`. +- `Cluster.token` — text: `user@realm!tokenid=secret`. + +The client resolves the target VM by `vmid` via `cluster/resources`, so it works +for both QEMU and LXC. + +## Settings (`PLUGINS_CONFIG["proxmox_power_button"]`) + +| key | default | meaning | +|---------------|--------------|------------------------------------------| +| `verify_ssl` | `False` | verify Proxmox TLS cert | +| `stop_mode` | `"shutdown"` | `shutdown` (graceful ACPI) or `stop` (hard) | +| `reboot_mode` | `"reboot"` | `reboot` (graceful) or `reset` (hard) | + +## Notes / caveats + +- **Token is stored in a plain-text custom field** and is visible to anyone who + can view the cluster. Use a scoped, least-privilege Proxmox API token and + restrict cluster view permissions. NetBox has no "secret" custom-field type. +- **Requires NetBox ≥ 4.5.0.** The data migration depends on + `extras.0134_owner` and `virtualization.0052_gfk_indexes`, which first appear + in 4.5.0; on 4.4 or older `migrate` fails with `NodeNotFoundError`. Bump + `min_version` together with those pins if you ever retarget them. + Verified against 4.5.8. +- Making `vmid` required means existing VMs without a VMID will fail validation + on their next edit until one is set. + +## Releasing to PyPI + +Everything runs through the `makefile`: + +```bash +make venv # one-off: build+twine in .venv-publish (PEP 668-safe) +make bump V=0.2.0 # writes the version to BOTH places, then verifies +make build # clean + sdist + wheel +make check # lists both artifacts, asserts contents, twine check +make testpypi # optional dry run against TestPyPI +make publish # build + check + upload (asks you to type the version) +make tag # git tag +``` + +Uploads authenticate with username `__token__` and a `pypi-…` API token +(`~/.pypirc`, or `TWINE_USERNAME`/`TWINE_PASSWORD`). **A version number is burned +permanently on upload** — it can never be reused, even after deleting the +release; hence `publish` refuses to run without a passing `check` and a typed +confirmation. + +The version lives in **two** places: `pyproject.toml` → `version`, and +`proxmox_power_button/__init__.py` → `ProxmoxPowerButtonConfig.version`. They are +not single-sourced on purpose — importing the package to read a version would +drag in `netbox`, absent in a build environment. `make bump` writes both and +`make version` fails loudly if they ever drift. + +`make check` asserts three things that a broken build would otherwise hide until +someone installs the package: the wheel carries the button template (`templates/` +has no `__init__.py`, so without `[tool.setuptools.package-data]` +`MANIFEST.in` +you get a wheel that raises `TemplateDoesNotExist` on every VM page), the +migration, and the licence. + +## Licence + +GPL-2.0-or-later. See `LICENSE` for the full text; every source file carries an +`SPDX-License-Identifier: GPL-2.0-or-later` header, which is what expresses the +"or later" option (the GPL-2 text alone does not). diff --git a/makefile b/makefile new file mode 100644 index 0000000..3198b39 --- /dev/null +++ b/makefile @@ -0,0 +1,96 @@ +#!make +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright (C) 2026 Bryan Joshua Pedini + +PYTHON ?= python3 +VENV ?= .venv-publish +PKG := proxmox_power_button +DIST := netbox-proxmox-power-button + +PY := $(VENV)/bin/python +TWINE := $(VENV)/bin/twine + +# Version lives in two places on purpose (importing the package to read it +# would drag in `netbox`, absent in a build env). `make bump` writes both, +# `make version` proves they agree. +PYPROJECT_VERSION = $(shell $(PYTHON) -c "import tomllib,pathlib;print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])") +CONFIG_VERSION = $(shell $(PYTHON) -c "import re,pathlib;print(re.search(r'version = \"([^\"]+)\"',pathlib.Path('$(PKG)/__init__.py').read_text()).group(1))") + +.PHONY: default help venv clean version bump build check testpypi publish tag + +default: help + +help: + @echo "make venv - create $(VENV) with build+twine (PEP 668-safe)" + @echo "make version - show the version and verify both copies agree" + @echo "make bump V=x.y.z - set the version in pyproject.toml and $(PKG)/__init__.py" + @echo "make build - clean, then build the sdist and the wheel" + @echo "make check - build, then verify the artifacts (contents + twine check)" + @echo "make testpypi - build, check, upload to TestPyPI" + @echo "make publish - build, check, upload to PyPI (asks to confirm)" + @echo "make tag - git tag " + @echo "make clean - remove dist/, build/, *.egg-info, __pycache__" + @echo "" + @echo "Uploads authenticate with username __token__ and a pypi-... API token." + @echo "Put it in ~/.pypirc or export TWINE_USERNAME/TWINE_PASSWORD." + +venv: + @test -x $(TWINE) || ( \ + $(PYTHON) -m venv $(VENV) && \ + $(VENV)/bin/pip --quiet install --upgrade pip build twine \ + ) + @echo "tooling ready: $(VENV)" + +clean: + rm -rf dist build *.egg-info + find . -path ./$(VENV) -prune -o -name __pycache__ -type d -prune -exec rm -rf {} + + +version: + @echo "pyproject.toml : $(PYPROJECT_VERSION)" + @echo "$(PKG)/__init__.py : $(CONFIG_VERSION)" + @test "$(PYPROJECT_VERSION)" = "$(CONFIG_VERSION)" || \ + ( echo "ERROR: versions disagree — run 'make bump V='"; exit 1 ) + +bump: + @test -n "$(V)" || ( echo "usage: make bump V=1.2.3"; exit 1 ) + sed -i 's/^version = ".*"/version = "$(V)"/' pyproject.toml + sed -i 's/^\( *\)version = ".*"/\1version = "$(V)"/' $(PKG)/__init__.py + @$(MAKE) --no-print-directory version + +build: venv version clean + $(PY) -m build + +# The wheel must carry the button template (templates/ has no __init__.py, so a +# misconfigured package-data silently ships a wheel that raises +# TemplateDoesNotExist on every VM page) and every migration. +check: build + @echo "--- wheel contents ---" + @$(PY) -m zipfile -l dist/*.whl | awk 'NR>1 {print $$1}' + @echo "--- sdist contents ---" + @tar tzf dist/*.tar.gz + @echo "--- assertions ---" + @$(PY) -m zipfile -l dist/*.whl | grep -q 'templates/$(PKG)/vm_power_buttons.html' \ + && echo "OK wheel ships the button template" \ + || ( echo "FAIL wheel is missing the button template"; exit 1 ) + @$(PY) -m zipfile -l dist/*.whl | grep -q '$(PKG)/migrations/0001_initial.py' \ + && echo "OK wheel ships the migration" \ + || ( echo "FAIL wheel is missing the migration"; exit 1 ) + @$(PY) -m zipfile -l dist/*.whl | grep -q 'dist-info/licenses/LICENSE' \ + && echo "OK wheel ships the licence" \ + || ( echo "FAIL wheel is missing the licence"; exit 1 ) + @$(TWINE) check dist/* + +testpypi: build check + $(TWINE) upload --repository testpypi dist/* + +# A version number is burned permanently on upload — never reusable, even after +# a delete. Hence the confirmation. +publish: build check + @echo "About to upload $(DIST) $(PYPROJECT_VERSION) to the real PyPI." + @read -p "This cannot be undone. Type the version to confirm: " v; \ + test "$$v" = "$(PYPROJECT_VERSION)" || ( echo "aborted"; exit 1 ) + $(TWINE) upload dist/* + +tag: + git tag $(PYPROJECT_VERSION) + @echo "tagged $(PYPROJECT_VERSION) — push with: git push --tags" diff --git a/proxmox_power_button/__init__.py b/proxmox_power_button/__init__.py new file mode 100644 index 0000000..6ca9e91 --- /dev/null +++ b/proxmox_power_button/__init__.py @@ -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 diff --git a/proxmox_power_button/migrations/0001_initial.py b/proxmox_power_button/migrations/0001_initial.py new file mode 100644 index 0000000..79af5c1 --- /dev/null +++ b/proxmox_power_button/migrations/0001_initial.py @@ -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), + ] diff --git a/proxmox_power_button/migrations/__init__.py b/proxmox_power_button/migrations/__init__.py new file mode 100644 index 0000000..d83b8f9 --- /dev/null +++ b/proxmox_power_button/migrations/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright (C) 2026 Bryan Joshua Pedini diff --git a/proxmox_power_button/models.py b/proxmox_power_button/models.py new file mode 100644 index 0000000..7739a4c --- /dev/null +++ b/proxmox_power_button/models.py @@ -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). diff --git a/proxmox_power_button/proxmox.py b/proxmox_power_button/proxmox.py new file mode 100644 index 0000000..6259d25 --- /dev/null +++ b/proxmox_power_button/proxmox.py @@ -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[^!]+)!(?P[^=]+)=(?P.+)$", (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 diff --git a/proxmox_power_button/signals.py b/proxmox_power_button/signals.py new file mode 100644 index 0000000..76112ea --- /dev/null +++ b/proxmox_power_button/signals.py @@ -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." + ) diff --git a/proxmox_power_button/template_content.py b/proxmox_power_button/template_content.py new file mode 100644 index 0000000..d6d1d19 --- /dev/null +++ b/proxmox_power_button/template_content.py @@ -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] diff --git a/proxmox_power_button/templates/proxmox_power_button/vm_power_buttons.html b/proxmox_power_button/templates/proxmox_power_button/vm_power_buttons.html new file mode 100644 index 0000000..92df723 --- /dev/null +++ b/proxmox_power_button/templates/proxmox_power_button/vm_power_buttons.html @@ -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. #} +
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+{% else %} + {# Powered off: start only. #} +
+ {% csrf_token %} + +
+{% endif %} diff --git a/proxmox_power_button/urls.py b/proxmox_power_button/urls.py new file mode 100644 index 0000000..b27c255 --- /dev/null +++ b/proxmox_power_button/urls.py @@ -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//power//", + views.VMPowerActionView.as_view(), + name="power", + ), +] diff --git a/proxmox_power_button/views.py b/proxmox_power_button/views.py new file mode 100644 index 0000000..35a7519 --- /dev/null +++ b/proxmox_power_button/views.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bfe673e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright (C) 2026 Bryan Joshua Pedini + +[build-system] +# >=77 for PEP 639 SPDX `license` / `license-files` support. +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +# Distribution name (what you `pip install`). The importable package — and the +# name that goes into NetBox's PLUGINS list — stays `proxmox_power_button`, +# matching the ecosystem convention (cf. netbox-plugin-dns / netbox_dns). +name = "netbox-proxmox-power-button" +version = "0.1.0" +description = "NetBox plugin adding Start/Stop/Reboot buttons that drive Proxmox VE VMs" +readme = "README.md" +license = "GPL-2.0-or-later" +license-files = ["LICENSE"] +authors = [{ name = "Bryan Joshua Pedini" }] +# NetBox 4.5 itself refuses to start on anything older than 3.12. +requires-python = ">=3.12" +keywords = ["netbox", "netbox-plugin", "proxmox", "virtualization"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Web Environment", + "Framework :: Django", + "Intended Audience :: System Administrators", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: System :: Systems Administration", +] +# NetBox itself is deliberately NOT a dependency: it is the host application, +# already present at install time. Compatibility is declared via +# PluginConfig.min_version instead. `requests` is needed explicitly — +# proxmoxer does not pull it in. +dependencies = [ + "proxmoxer>=2.0", + "requests", +] + +[project.urls] +Homepage = "https://git.bjphoster.com/source/netbox-proxmox-power-button" +Repository = "https://git.bjphoster.com/source/netbox-proxmox-power-button" +Issues = "https://git.bjphoster.com/source/netbox-proxmox-power-button/issues" + +[tool.setuptools.packages.find] +include = ["proxmox_power_button*"] + +[tool.setuptools.package-data] +# templates/ has no __init__.py, so packages.find never sees it — the button +# HTML must be listed explicitly or the wheel ships without it and every VM +# page raises TemplateDoesNotExist. Widen this glob if static/ or further +# template subdirectories are ever added. +proxmox_power_button = ["templates/proxmox_power_button/*.html"]