You've already forked netbox-proxmox-power-button
added proxmox power button plugin 0.1
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
.venv-publish/
|
||||||
3
MANIFEST.in
Normal file
3
MANIFEST.in
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
include LICENSE
|
||||||
|
include README.md
|
||||||
|
recursive-include proxmox_power_button/templates *.html
|
||||||
139
README.md
139
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 <version>
|
||||||
|
```
|
||||||
|
|
||||||
|
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).
|
||||||
|
|||||||
96
makefile
Normal file
96
makefile
Normal file
@@ -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 <version>"
|
||||||
|
@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=<version>'"; 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"
|
||||||
50
proxmox_power_button/__init__.py
Normal file
50
proxmox_power_button/__init__.py
Normal 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
|
||||||
97
proxmox_power_button/migrations/0001_initial.py
Normal file
97
proxmox_power_button/migrations/0001_initial.py
Normal 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),
|
||||||
|
]
|
||||||
2
proxmox_power_button/migrations/__init__.py
Normal file
2
proxmox_power_button/migrations/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
# Copyright (C) 2026 Bryan Joshua Pedini
|
||||||
5
proxmox_power_button/models.py
Normal file
5
proxmox_power_button/models.py
Normal 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).
|
||||||
151
proxmox_power_button/proxmox.py
Normal file
151
proxmox_power_button/proxmox.py
Normal 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
|
||||||
40
proxmox_power_button/signals.py
Normal file
40
proxmox_power_button/signals.py
Normal 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."
|
||||||
|
)
|
||||||
21
proxmox_power_button/template_content.py
Normal file
21
proxmox_power_button/template_content.py
Normal 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]
|
||||||
@@ -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 %}
|
||||||
13
proxmox_power_button/urls.py
Normal file
13
proxmox_power_button/urls.py
Normal 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",
|
||||||
|
),
|
||||||
|
]
|
||||||
113
proxmox_power_button/views.py
Normal file
113
proxmox_power_button/views.py
Normal 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)
|
||||||
58
pyproject.toml
Normal file
58
pyproject.toml
Normal file
@@ -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"]
|
||||||
Reference in New Issue
Block a user