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:
+3
-7
@@ -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
|
||||
|
||||
@@ -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
@@ -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 "$@"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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 0–100"),
|
||||
("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"),
|
||||
|
||||
Reference in New Issue
Block a user