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 \
|
RUN DEBIAN_FRONTEND=noninteractive apt-get update \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
iputils-ping \
|
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 — minimal privilege-drop tool used by entrypoint.sh
|
||||||
gosu \
|
gosu \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
@@ -23,10 +20,9 @@ COPY alembic.ini .
|
|||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
# Runtime directories — owned by app user.
|
# Runtime directories — owned by app user. The container starts as root
|
||||||
# The container runs as root so the entrypoint can start NUT daemons (USB
|
# so the entrypoint can fix up /data permissions if needed, then drops to
|
||||||
# access requires root); it drops to 'app' (uid 1000) via gosu before
|
# 'app' (uid 1000) via gosu before launching roundtable.
|
||||||
# launching roundtable.
|
|
||||||
RUN useradd -m -u 1000 app \
|
RUN useradd -m -u 1000 app \
|
||||||
&& mkdir -p /data/playbook_cache /data/plugins \
|
&& mkdir -p /data/playbook_cache /data/plugins \
|
||||||
&& chown -R app:app /data /app
|
&& chown -R app:app /data /app
|
||||||
|
|||||||
@@ -94,18 +94,3 @@ plugins:
|
|||||||
- network
|
- network
|
||||||
- unifi
|
- unifi
|
||||||
- ubiquiti
|
- 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
|
#!/bin/sh
|
||||||
# Roundtable container entrypoint.
|
# Roundtable container entrypoint.
|
||||||
#
|
#
|
||||||
# When NUT_MANAGED=1:
|
# Drops from root to the 'app' user via gosu, then runs the command.
|
||||||
# - Generates /etc/nut/*.conf from environment variables
|
# The container starts as root only so /data permissions can be fixed up
|
||||||
# - Starts NUT driver (upsdrvctl) and network daemon (upsd)
|
# if needed before handing off to the unprivileged user.
|
||||||
# - 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
|
|
||||||
|
|
||||||
set -e
|
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 "$@"
|
exec gosu app "$@"
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ METRIC_CATALOG: dict[str, list[str]] = {
|
|||||||
"traefik": ["request_rate", "error_rate", "latency_p50_ms", "latency_p95_ms",
|
"traefik": ["request_rate", "error_rate", "latency_p50_ms", "latency_p95_ms",
|
||||||
"latency_p99_ms", "response_bytes_rate", "cert_expiry_days"],
|
"latency_p99_ms", "response_bytes_rate", "cert_expiry_days"],
|
||||||
"unifi": ["is_up", "latency_ms", "total_clients"],
|
"unifi": ["is_up", "latency_ms", "total_clients"],
|
||||||
"ups": ["battery_charge_pct", "on_battery", "battery_runtime_secs"],
|
|
||||||
"docker": ["cpu_pct", "mem_pct"],
|
"docker": ["cpu_pct", "mem_pct"],
|
||||||
"http": ["is_up", "response_ms"],
|
"http": ["is_up", "response_ms"],
|
||||||
# snmp metrics are user-defined OID labels — populated dynamically from plugin_metrics
|
# 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": {
|
"docker_containers": {
|
||||||
"key": "docker_containers",
|
"key": "docker_containers",
|
||||||
"label": "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
|
plugin_cfg[cfg_key] = default_val
|
||||||
else:
|
else:
|
||||||
plugin_cfg[cfg_key] = raw_val
|
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
|
return plugin_cfg
|
||||||
|
|
||||||
|
|
||||||
@@ -483,9 +478,6 @@ async def save_plugin_detail(name: str):
|
|||||||
from roundtable.core.plugin_manager import hot_reload_plugin
|
from roundtable.core.plugin_manager import hot_reload_plugin
|
||||||
hot_reload_plugin(current_app._get_current_object(), name)
|
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))
|
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})
|
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) ─────────────────────────────────────────────
|
# ── Plugin catalog (HTMX partial) ─────────────────────────────────────────────
|
||||||
|
|
||||||
@settings_bp.get("/plugins/catalog/")
|
@settings_bp.get("/plugins/catalog/")
|
||||||
|
|||||||
@@ -109,9 +109,6 @@
|
|||||||
"unifi": [("is_up", "1.0 = WAN up, 0.0 = down"),
|
"unifi": [("is_up", "1.0 = WAN up, 0.0 = down"),
|
||||||
("latency_ms", "WAN latency in ms"),
|
("latency_ms", "WAN latency in ms"),
|
||||||
("total_clients", "number of connected clients")],
|
("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 %"),
|
"docker": [("cpu_pct", "container CPU usage %"),
|
||||||
("mem_pct", "container memory usage %")],
|
("mem_pct", "container memory usage %")],
|
||||||
"http": [("is_up", "1.0 = check passed, 0.0 = failed"),
|
"http": [("is_up", "1.0 = check passed, 0.0 = failed"),
|
||||||
|
|||||||
Reference in New Issue
Block a user