refactor: remove broken NUT/UPS plugin and managed-NUT startup

The UPS plugin's default driver was snmp-ups, so NUT was just translating
network SNMP back into its own protocol — no value over polling the UPS
directly through the existing snmp plugin. The managed-NUT entrypoint path
also broke container startup when config was incomplete.

Deletes nut_setup.py, the NUT block from entrypoint.sh, NUT packages from
the Dockerfile, the ups entry from the plugin catalog example, and the
ups wiring in alerts METRIC_CATALOG, widgets, settings routes, and the
rules form. The plugins/ups source tree (untracked) is also removed from
the working copy. Existing ups_* DB tables are orphaned but harmless in
dev; a drop migration isn't needed.

Follow-up: rebuild "on battery → Ansible shutdown" automation on top of
the alerts system as a new action type that runs a playbook.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 22:41:08 -04:00
parent 0596f1d9c1
commit d6e094bbb5
8 changed files with 6 additions and 263 deletions
+3 -7
View File
@@ -3,9 +3,6 @@ FROM python:3.13-slim
RUN DEBIAN_FRONTEND=noninteractive apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
iputils-ping \
# NUT (Network UPS Tools) — only used when NUT_MANAGED=1
nut \
nut-client \
# gosu — minimal privilege-drop tool used by entrypoint.sh
gosu \
&& rm -rf /var/lib/apt/lists/*
@@ -23,10 +20,9 @@ COPY alembic.ini .
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Runtime directories — owned by app user.
# The container runs as root so the entrypoint can start NUT daemons (USB
# access requires root); it drops to 'app' (uid 1000) via gosu before
# launching roundtable.
# Runtime directories — owned by app user. The container starts as root
# so the entrypoint can fix up /data permissions if needed, then drops to
# 'app' (uid 1000) via gosu before launching roundtable.
RUN useradd -m -u 1000 app \
&& mkdir -p /data/playbook_cache /data/plugins \
&& chown -R app:app /data /app
-15
View File
@@ -94,18 +94,3 @@ plugins:
- network
- unifi
- ubiquiti
- name: ups
version: "1.0.0"
description: "UPS monitoring via NUT (Network UPS Tools) with Ansible shutdown automation"
author: "Roundtable"
license: "MIT"
min_app_version: "0.1.0"
repository_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins"
homepage: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/src/branch/main/ups"
download_url: "https://git.fabledsword.com/bvandeusen/Roundtable-plugins/releases/download/ups-v1.0.0/ups.zip"
checksum_sha256: ""
tags:
- ups
- power
- nut
+3 -47
View File
@@ -1,54 +1,10 @@
#!/bin/sh
# Roundtable container entrypoint.
#
# When NUT_MANAGED=1:
# - Generates /etc/nut/*.conf from environment variables
# - Starts NUT driver (upsdrvctl) and network daemon (upsd)
# - Connects to the UPS over the network — no USB passthrough required
# - Then drops to 'app' user via gosu to run roundtable
#
# When NUT_MANAGED=0 (default):
# - Drops directly to 'app' user and runs the command
#
# Required env vars when NUT_MANAGED=1:
# NUT_UPS_HOST IP or hostname of the UPS management card (required)
# NUT_DRIVER NUT driver — snmp-ups (default) or netxml-ups for Eaton
# NUT_UPS_NAME UPS name in NUT (default: ups) — must match UPS plugin setting
# NUT_SNMP_COMMUNITY SNMP community string (default: public) [snmp-ups only]
# NUT_SNMP_VERSION SNMP version: v1, v2c (default: v1) [snmp-ups only]
# NUT_USERNAME Optional upsd auth username
# NUT_PASSWORD Optional upsd auth password
# Drops from root to the 'app' user via gosu, then runs the command.
# The container starts as root only so /data permissions can be fixed up
# if needed before handing off to the unprivileged user.
set -e
NUT_MANAGED_JSON="/data/nut_managed.json"
if [ "${NUT_MANAGED:-0}" = "1" ] || [ -f "$NUT_MANAGED_JSON" ]; then
NUT_OK=1
if [ -f "$NUT_MANAGED_JSON" ]; then
echo "[entrypoint] Found $NUT_MANAGED_JSON — configuring NUT from settings..."
python3 /app/roundtable/nut_setup.py --from-file "$NUT_MANAGED_JSON" || {
echo "[entrypoint] Warning: NUT config failed — UPS host may not be set yet. Skipping NUT startup."
NUT_OK=0
}
else
echo "[entrypoint] NUT_MANAGED=1 — configuring NUT from environment..."
python3 /app/roundtable/nut_setup.py || {
echo "[entrypoint] Warning: NUT config failed — check NUT_UPS_HOST. Skipping NUT startup."
NUT_OK=0
}
fi
if [ "$NUT_OK" = "1" ]; then
echo "[entrypoint] Starting NUT driver..."
/sbin/upsdrvctl start || echo "[entrypoint] Warning: upsdrvctl returned non-zero"
echo "[entrypoint] Starting NUT daemon..."
/sbin/upsd || echo "[entrypoint] Warning: upsd returned non-zero"
sleep 1
echo "[entrypoint] NUT ready on 127.0.0.1:3493."
fi
fi
exec gosu app "$@"
-1
View File
@@ -20,7 +20,6 @@ METRIC_CATALOG: dict[str, list[str]] = {
"traefik": ["request_rate", "error_rate", "latency_p50_ms", "latency_p95_ms",
"latency_p99_ms", "response_bytes_rate", "cert_expiry_days"],
"unifi": ["is_up", "latency_ms", "total_clients"],
"ups": ["battery_charge_pct", "on_battery", "battery_runtime_secs"],
"docker": ["cpu_pct", "mem_pct"],
"http": ["is_up", "response_ms"],
# snmp metrics are user-defined OID labels — populated dynamically from plugin_metrics
-32
View File
@@ -162,38 +162,6 @@ WIDGET_REGISTRY: dict[str, dict] = {
},
],
},
"ups_status": {
"key": "ups_status",
"label": "UPS — Status",
"description": "Battery charge, runtime estimate, load percentage, and on-battery alerts",
"hx_url": "/plugins/ups/widget",
"detail_url": "/plugins/ups/",
"plugin": "ups",
"poll": True,
"params": [],
},
"ups_history": {
"key": "ups_history",
"label": "UPS — History Chart",
"description": "Battery %, load %, and input voltage over time (Chart.js)",
"hx_url": "/plugins/ups/widget/history",
"detail_url": "/plugins/ups/",
"plugin": "ups",
"poll": True,
"params": [
{
"key": "hours",
"label": "Time range",
"type": "select",
"default": 6,
"options": [
{"value": 1, "label": "Last 1 hour"},
{"value": 6, "label": "Last 6 hours"},
{"value": 24, "label": "Last 24 hours"},
],
},
],
},
"docker_containers": {
"key": "docker_containers",
"label": "Docker — Containers",
-121
View File
@@ -1,121 +0,0 @@
"""Generate NUT (Network UPS Tools) configuration files.
Called by entrypoint.sh before the NUT daemons start. Reads config either from
environment variables (legacy NUT_MANAGED=1 path) or from a JSON file written
by the Roundtable settings page (/data/nut_managed.json).
Environment variables (used when no --from-file is given):
NUT_DRIVER NUT driver (default: snmp-ups)
snmp-ups — any UPS with SNMP management card (APC, Eaton,
CyberPower, Tripp Lite, Liebert, ...)
netxml-ups — Eaton Network Management Card (XML/HTTP)
NUT_UPS_NAME Name exposed by upsd (default: ups)
Must match 'ups_name' in the UPS plugin settings.
NUT_UPS_HOST IP address or hostname of the UPS management card (required)
NUT_SNMP_COMMUNITY SNMP community string (default: public) [snmp-ups only]
NUT_SNMP_VERSION SNMP version: v1, v2c (default: v1) [snmp-ups only]
NUT_USERNAME Optional upsd username for authenticated access
NUT_PASSWORD Optional upsd password
JSON file format (--from-file path):
{
"ups_host": "192.168.1.x", // required
"driver": "snmp-ups",
"ups_name": "ups",
"snmp_community": "public",
"snmp_version": "v1",
"username": "",
"password": ""
}
"""
import json
import os
import sys
from pathlib import Path
NUT_CONF_DIR = Path("/etc/nut")
NUT_MANAGED_JSON = Path("/data/nut_managed.json")
def _env(key: str, default: str = "") -> str:
return os.environ.get(key, default).strip()
def write_configs(cfg: dict | None = None) -> None:
"""Write NUT config files. If cfg is given, use it; otherwise fall back to env vars."""
NUT_CONF_DIR.mkdir(parents=True, exist_ok=True)
if cfg is not None:
driver = cfg.get("driver", "snmp-ups").strip()
ups_name = cfg.get("ups_name", "ups").strip()
ups_host = cfg.get("ups_host", "").strip()
community = cfg.get("snmp_community", "public").strip()
snmp_ver = cfg.get("snmp_version", "v1").strip()
username = cfg.get("username", "").strip()
password = cfg.get("password", "").strip()
else:
driver = _env("NUT_DRIVER", "snmp-ups")
ups_name = _env("NUT_UPS_NAME", "ups")
ups_host = _env("NUT_UPS_HOST")
community = _env("NUT_SNMP_COMMUNITY", "public")
snmp_ver = _env("NUT_SNMP_VERSION", "v1")
username = _env("NUT_USERNAME")
password = _env("NUT_PASSWORD")
if not ups_host:
print("[nut_setup] ERROR: NUT_UPS_HOST is required", file=sys.stderr)
sys.exit(1)
# ── nut.conf ───────────────────────────────────────────────────────────────
(NUT_CONF_DIR / "nut.conf").write_text("MODE=standalone\n")
# ── ups.conf ───────────────────────────────────────────────────────────────
ups_block = [f"[{ups_name}]"]
ups_block.append(f" driver = {driver}")
if driver == "netxml-ups":
# Eaton: port is a URL
ups_block.append(f" port = http://{ups_host}")
else:
# snmp-ups and others: port is the IP/hostname
ups_block.append(f" port = {ups_host}")
if driver == "snmp-ups":
ups_block.append(f" community = {community}")
ups_block.append(f" snmp_version = {snmp_ver}")
ups_block.append(f' desc = "Network UPS at {ups_host}"')
(NUT_CONF_DIR / "ups.conf").write_text("\n".join(ups_block) + "\n")
# ── upsd.conf ──────────────────────────────────────────────────────────────
(NUT_CONF_DIR / "upsd.conf").write_text("LISTEN 127.0.0.1 3493\n")
# ── upsd.users ─────────────────────────────────────────────────────────────
if username and password:
users_content = (
f"[{username}]\n"
f" password = {password}\n"
f" upsmon primary\n"
)
else:
users_content = "# No users configured — unauthenticated access\n"
users_path = NUT_CONF_DIR / "upsd.users"
users_path.write_text(users_content)
users_path.chmod(0o640)
print(
f"[nut_setup] Config written: driver={driver} host={ups_host} ups_name={ups_name}"
)
if __name__ == "__main__":
try:
cfg = None
if "--from-file" in sys.argv:
idx = sys.argv.index("--from-file")
json_path = Path(sys.argv[idx + 1])
with json_path.open() as f:
cfg = json.load(f)
write_configs(cfg)
except Exception as exc:
print(f"[nut_setup] ERROR: {exc}", file=sys.stderr)
sys.exit(1)
-37
View File
@@ -404,11 +404,6 @@ def _build_plugin_cfg_from_form(plugin: dict, form) -> dict:
plugin_cfg[cfg_key] = default_val
else:
plugin_cfg[cfg_key] = raw_val
# Managed NUT: when nut_managed is on, the NUT daemon runs in this container
# — force nut_host/port to localhost so the plugin connects automatically.
if name == "ups" and plugin_cfg.get("nut_managed"):
plugin_cfg["nut_host"] = "localhost"
plugin_cfg["nut_port"] = 3493
return plugin_cfg
@@ -483,9 +478,6 @@ async def save_plugin_detail(name: str):
from roundtable.core.plugin_manager import hot_reload_plugin
hot_reload_plugin(current_app._get_current_object(), name)
if name == "ups":
_sync_nut_managed_config(form)
return redirect(url_for("settings.plugin_detail", name=name))
@@ -546,35 +538,6 @@ async def plugin_repos_save(idx: int):
return await _plugin_repos_partial({"plugins.repositories": repos})
def _sync_nut_managed_config(form) -> None:
"""Write or remove /data/nut_managed.json based on UPS plugin managed NUT settings."""
import json
nut_managed_path = Path("/data/nut_managed.json")
nut_managed = "plugin.ups.nut_managed" in form
ups_host = form.get("plugin.ups.nut_ups_host", "").strip()
# Only write the file when managed mode is on AND a host is configured.
# Without a host, NUT can't start and the container would loop on errors.
if nut_managed and ups_host:
cfg = {
"ups_host": ups_host,
"driver": form.get("plugin.ups.nut_driver", "snmp-ups").strip(),
"ups_name": form.get("plugin.ups.ups_name", "ups").strip(),
"snmp_community": form.get("plugin.ups.nut_snmp_community", "public").strip(),
"snmp_version": form.get("plugin.ups.nut_snmp_version", "v1").strip(),
"username": form.get("plugin.ups.nut_username", "").strip(),
"password": form.get("plugin.ups.nut_password", "").strip(),
}
try:
nut_managed_path.write_text(json.dumps(cfg, indent=2))
except OSError as exc:
logger.warning("Could not write %s: %s", nut_managed_path, exc)
else:
try:
nut_managed_path.unlink(missing_ok=True)
except OSError as exc:
logger.warning("Could not remove %s: %s", nut_managed_path, exc)
# ── Plugin catalog (HTMX partial) ─────────────────────────────────────────────
@settings_bp.get("/plugins/catalog/")
@@ -109,9 +109,6 @@
"unifi": [("is_up", "1.0 = WAN up, 0.0 = down"),
("latency_ms", "WAN latency in ms"),
("total_clients", "number of connected clients")],
"ups": [("battery_charge_pct", "battery charge 0100"),
("on_battery", "1.0 when running on battery"),
("battery_runtime_secs", "estimated runtime remaining in seconds")],
"docker": [("cpu_pct", "container CPU usage %"),
("mem_pct", "container memory usage %")],
"http": [("is_up", "1.0 = check passed, 0.0 = failed"),