diff --git a/Dockerfile b/Dockerfile index 26c8aec..6f08158 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docs/plugins/index.yaml.example b/docs/plugins/index.yaml.example index 23770e2..e1b5506 100644 --- a/docs/plugins/index.yaml.example +++ b/docs/plugins/index.yaml.example @@ -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 diff --git a/entrypoint.sh b/entrypoint.sh index 97f9a4e..ea128e0 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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 "$@" diff --git a/roundtable/alerts/routes.py b/roundtable/alerts/routes.py index 5831a42..22b661f 100644 --- a/roundtable/alerts/routes.py +++ b/roundtable/alerts/routes.py @@ -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 diff --git a/roundtable/core/widgets.py b/roundtable/core/widgets.py index 24ffc2f..8d678c2 100644 --- a/roundtable/core/widgets.py +++ b/roundtable/core/widgets.py @@ -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", diff --git a/roundtable/nut_setup.py b/roundtable/nut_setup.py deleted file mode 100644 index 828ea3f..0000000 --- a/roundtable/nut_setup.py +++ /dev/null @@ -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) diff --git a/roundtable/settings/routes.py b/roundtable/settings/routes.py index d1f069e..a75910c 100644 --- a/roundtable/settings/routes.py +++ b/roundtable/settings/routes.py @@ -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/") diff --git a/roundtable/templates/alerts/rules_form.html b/roundtable/templates/alerts/rules_form.html index 2eb1130..f1a148f 100644 --- a/roundtable/templates/alerts/rules_form.html +++ b/roundtable/templates/alerts/rules_form.html @@ -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"),