diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1909806 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,29 @@ +# Git +.git +.github + +# Python virtual environments +.venv +src/backend/.venv +src/backend/__pycache__ +src/backend/.pytest_cache +**/__pycache__ +**/.pytest_cache + +# Frontend build intermediates +src/frontend/node_modules +src/frontend/dist + +# Distribution artifacts +nutwatch.tar.gz + +# Editor / local config +.opencode +.claude +.coderabbit.yaml +.playwright-cli +.vscode +.idea + +# Documentation assets (not needed at runtime) +docs/screenshots diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 29e255e..acc2b15 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5,6 +5,9 @@ on: branches: [main] pull_request: +permissions: + contents: read + jobs: lint-shell: runs-on: ubuntu-latest @@ -14,11 +17,12 @@ jobs: - name: Install tools run: sudo apt-get install -y shellcheck shfmt + # Same file set as `make lint` / `make fmt` (see Makefile SHELL_FILES). - name: shellcheck - run: find vm/ -name "*.sh" -print0 | xargs -0 shellcheck + run: find vm/ src/backend/ scripts/ -name "*.sh" -print0 | xargs -0 shellcheck - name: shfmt - run: find vm/ -name "*.sh" -print0 | xargs -0 shfmt -d -i 2 + run: find vm/ src/backend/ scripts/ -name "*.sh" -print0 | xargs -0 shfmt -d -i 2 lint-python: runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 0dfcb25..e410c89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ make install-tools # apt-get shellcheck shfmt python3-pytest # python3 -m venv .venv && source .venv/bin/activate && pip install -r src/backend/requirements.txt ``` -CI runs `shellcheck` + `shfmt -d -i 2` on `vm/*.sh` and Python lint + tests (see `.github/workflows/lint.yml`). `make check` reproduces the full local suite. +CI runs `shellcheck` + `shfmt -d -i 2` on `vm/`, `src/backend/`, and `scripts/` (same set as the Makefile's `SHELL_FILES`) plus Python lint + tests (see `.github/workflows/lint.yml`). `make check` reproduces the full local suite. ## Shell Conventions @@ -63,6 +63,7 @@ CI runs `shellcheck` + `shfmt -d -i 2` on `vm/*.sh` and Python lint + tests (see - Slow DHCP / guest agent: retries for up to 5 minutes. - virt-customize network failure on Debian 13 (Proxmox VE 9): auto-installs `dhcpcd-base` when missing. - NUT service enablement varies by distro: `nut-driver-enumerator` → `nut-driver@` → `nut-driver`. Each unit is enabled individually with `|| true` so missing units don't abort the whole run. +- NUT 2.8.x has no bare `nut-driver.service` on most distros (drivers run as `nut-driver@` instances); `services/system.py::_driver_status_units()` resolves the right unit for status/restart and `routes/logs.py::_journal_units()` uses it for journalctl, falling back to pid-file checks or `upsdrvctl`. - NutWatch install failure inside virt-customize: wrapped in `&& ... || echo` so a download failure doesn't abort the VM setup. - Script interruption: `trap ERR` calls `error_handler`, `trap EXIT` runs `cleanup` (removes temp dir and working disk image), and `trap SIGINT/SIGTERM` posts failure to the API before exiting. - Hook ownership: per-UPS hook scripts must be `root:nut 750` so `upsmon` (running as the `nut` user) can execute them. `services/hooks.py::put_hook()` explicitly `chown`s to `root:nut` after writing. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d073129 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,73 @@ +# syntax=docker/dockerfile:1 + +# Multi-stage build for NutWatch. +# Stage 1 builds the React frontend; Stage 2 is the runtime image with NUT. + +FROM node:22-slim AS frontend-builder +# Build the React SPA. Vite writes to ../backend/static, so we copy the +# backend tree into the same relative location before building. +WORKDIR /build/src/frontend +COPY src/frontend/package.json src/frontend/package-lock.json ./ +RUN npm ci +COPY src/frontend/ ./ +COPY src/backend/ /build/src/backend/ +RUN npm run build + +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive +ENV NUTWATCH_DIR=/opt/nutwatch +ENV NUTWATCH_HOST=0.0.0.0 +ENV NUTWATCH_PORT=8081 +ENV NUT_LISTEN_ADDR=0.0.0.0 +ENV NUT_LISTEN_PORT=3493 +ENV PYTHONUNBUFFERED=1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + nut-server \ + nut-client \ + usbutils \ + busybox-syslogd \ + iproute2 \ + iputils-arping \ + iputils-ping \ + python3 \ + python3-venv \ + supervisor \ + tini \ + curl \ + openssl \ + && rm -rf /var/lib/apt/lists/* \ + && rm -f /etc/nut/ups.conf /etc/nut/upsd.conf /etc/nut/upsd.users \ + /etc/nut/upsmon.conf /etc/nut/nut.conf \ + && rm -f /etc/syslog.conf + +WORKDIR $NUTWATCH_DIR + +# Copy backend application with freshly built frontend static files. +COPY --from=frontend-builder /build/src/backend/ $NUTWATCH_DIR/ + +# Install Python dependencies. +RUN python3 -m venv $NUTWATCH_DIR/venv \ + && $NUTWATCH_DIR/venv/bin/pip install --no-cache-dir -r $NUTWATCH_DIR/requirements.txt + +# Copy Docker runtime helpers. +COPY scripts/docker/entrypoint.sh /entrypoint.sh +COPY scripts/docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf +COPY scripts/docker/systemctl-shim.sh /usr/local/bin/systemctl +COPY scripts/docker/upsmon-wrapper.sh /usr/local/bin/upsmon-wrapper + +RUN chmod +x /entrypoint.sh /usr/local/bin/systemctl /usr/local/bin/upsmon-wrapper \ + && mkdir -p /etc/nut/notify.d /var/log/nut /var/run/nut /var/lib/nutwatch /var/log/supervisor \ + && chown -R root:nut /etc/nut \ + && chmod 750 /etc/nut /etc/nut/notify.d \ + && chown nut:nut /var/log/nut /var/run/nut \ + && chmod 755 /var/lib/nutwatch + +# Persist NUT configuration and NutWatch data (accounts, history, API keys). +VOLUME ["/etc/nut", "/var/lib/nutwatch"] + +EXPOSE 8081 3493 + +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["/entrypoint.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3531944 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 nutwatch contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 287b215..e428e1a 100644 --- a/README.md +++ b/README.md @@ -426,6 +426,77 @@ curl -fsSL https://raw.githubusercontent.com/JuanCF/nutwatch/main/scripts/setup. Set the `NUTWATCH_REF` env var to pin a specific release version. +### Docker + +A multi-stage `Dockerfile` and `docker-compose.yml` are included. The image +bundles NUT, the NutWatch backend, and a built React frontend. + +```bash +# Build and run with Docker Compose +docker compose up -d + +# Or build and run manually +docker build -t nutwatch . +docker run -d \ + --name nutwatch \ + --device /dev/bus/usb:/dev/bus/usb \ + --device-cgroup-rule 'c 189:* rwm' \ + -p 8081:8081 \ + -p 3493:3493 \ + -v nutwatch-config:/etc/nut \ + -v nutwatch-data:/var/lib/nutwatch \ + nutwatch +``` + +**USB access.** NUT drivers need to talk to the UPS over USB. The container +gets the host USB bus plus an allow-rule for USB character devices: + +```bash +docker run -d \ + --device /dev/bus/usb:/dev/bus/usb \ + --device-cgroup-rule 'c 189:* rwm' \ + ... nutwatch +``` + +The cgroup rule (major number 189 is USB) is evaluated when devices appear, +so UPS hotplug works without privileged mode. If USB access still fails on +your setup, `--privileged` (or `privileged: true` in compose) is the +documented fallback. + +**Credentials.** On first start the entrypoint generates random passwords for +the NUT `admin` and `monuser` users and writes them to `/etc/nut/upsd.users` +inside the `nutwatch-config` volume; the Flask session key is generated by the +backend and persisted in the auth database inside `nutwatch-data`. Generated +values are not printed to logs. To use your own credentials, set +`NUT_ADMIN_PASS` / `NUT_MONITOR_PASS` (and optionally `NUT_ADMIN_USER` / +`NUT_MONITOR_USER` or `NUTWATCH_SECRET_KEY`, at least 32 characters) before +the first start. Because `upsd.users` is only initialized on first start, +rotate existing credentials via the NutWatch UI (NUT Users tab, which +restarts NUT automatically) or the Config Files tab instead of environment +variables. + +**Persistent data.** Two volumes are used: + +- `/etc/nut` — NUT configuration files (`ups.conf`, `upsd.users`, `upsmon.conf`, + hooks in `notify.d/`, etc.) +- `/var/lib/nutwatch` — NutWatch account/API-key database and UPS history SQLite + database + +**Container notes.** + +- The container uses `supervisord` instead of `systemd`. A small `systemctl` + shim maps the UI's service-restart actions to `supervisorctl` so NUT service + restarts still work. +- System-level actions (reboot/shutdown) are disabled inside the container. +- Live log streaming relies on `journalctl`; in the container this is not + available. Use `docker exec nutwatch tail -f /var/log/supervisor/upsd.log` or + inspect the individual supervisor log files instead. +- Wake on LAN: magic packets default to the `255.255.255.255` broadcast, + which does not leave Docker's default bridge network. For WOL to reach + hosts on your LAN, either run the container with `--network host` + (`network_mode: host` in compose) or set a directed broadcast address + (e.g. `192.168.1.255`) on each WOL target. + ### Proxmox VM (One-Liner) ```bash diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9aa0c03 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +services: + nutwatch: + build: . + container_name: nutwatch + # USB access: mount the host USB bus and allow USB character devices via + # the device cgroup. The cgroup rule is evaluated when devices appear, so + # UPS hotplug works without privileged mode. Trust boundary: the container + # can then drive every USB device on the host bus. To scope it down, + # replace the mount with a specific /dev/bus/usb// device when + # your UPS always reappears at the same path. + devices: + - /dev/bus/usb:/dev/bus/usb + device_cgroup_rules: + - 'c 189:* rwm' + # Fallback: replace the two blocks above with `privileged: true` if USB + # access still fails on your setup. + # Share the host network stack: WOL device discovery needs the LAN's ARP + # table, and magic-packet broadcasts must reach the physical network. + # With host networking, `ports:` is ignored; NutWatch serves directly on + # host ports 8081 (UI) and 3493 (NUT). Note: until the first admin + # account is created on the Setup page, the UI is open — run the first + # `docker compose up` on a trusted network and finish setup promptly. + network_mode: host + volumes: + - nutwatch-config:/etc/nut + - nutwatch-data:/var/lib/nutwatch + environment: + - NUT_UPS_NAME=ups + - NUT_UPS_DESC=My UPS + - NUT_DRIVER=usbhid-ups + - NUT_ADMIN_USER=admin + - NUT_MONITOR_USER=monuser + # Optional. Credentials are generated on first start and persisted in + # the nutwatch-config volume. Set these before the first + # `docker compose up` to use your own: + # - NUT_ADMIN_PASS=your-admin-password + # - NUT_MONITOR_PASS=your-monitor-password + # Optional. Flask session signing key (>= 32 characters). If unset, the + # backend generates a random key and persists it in nutwatch-data. + # - NUTWATCH_SECRET_KEY=your-long-random-secret + restart: unless-stopped + +volumes: + nutwatch-config: + nutwatch-data: diff --git a/scripts/docker/entrypoint.sh b/scripts/docker/entrypoint.sh new file mode 100644 index 0000000..312810b --- /dev/null +++ b/scripts/docker/entrypoint.sh @@ -0,0 +1,146 @@ +#!/bin/bash +# Docker entrypoint for NutWatch. +# Generates a minimal NUT configuration on first boot, ensures permissions, +# starts USB drivers, and then hands off to supervisord. + +set -euo pipefail + +NUT_UPS_NAME="${NUT_UPS_NAME:-ups}" +NUT_UPS_DESC="${NUT_UPS_DESC:-My UPS}" +NUT_DRIVER="${NUT_DRIVER:-usbhid-ups}" +NUT_ADMIN_USER="${NUT_ADMIN_USER:-admin}" +NUT_ADMIN_PASS="${NUT_ADMIN_PASS:-}" +NUT_MONITOR_USER="${NUT_MONITOR_USER:-monuser}" +NUT_MONITOR_PASS="${NUT_MONITOR_PASS:-}" + +# Generate a password only when the caller did not provide one. Stick to +# alphanumerics: NUT's config parser treats '#' as the start of a comment +# and would silently truncate the password (and the rest of the line). +generate_password() { + local length="${1:-16}" + openssl rand -base64 48 2>/dev/null | tr -dc 'a-zA-Z0-9' | head -c "$length" || true +} + +if [[ -z "$NUT_ADMIN_PASS" ]]; then + NUT_ADMIN_PASS="$(generate_password 16)" + echo "[nutwatch] Generated NUT admin credentials." +fi + +if [[ -z "$NUT_MONITOR_PASS" ]]; then + NUT_MONITOR_PASS="$(generate_password 16)" + echo "[nutwatch] Generated NUT monitor credentials." +fi + +mkdir -p /etc/nut/notify.d /var/log/nut /var/run/nut /var/lib/nutwatch /var/log/supervisor + +if [[ ! -f /etc/nut/nut.conf ]]; then + echo 'MODE=netserver' >/etc/nut/nut.conf +fi + +if [[ ! -f /etc/nut/upsd.conf ]]; then + cat >/etc/nut/upsd.conf </etc/nut/upsd.users </etc/nut/ups.conf </etc/nut/upsmon.conf </dev/null || true +chmod 640 /etc/nut/*.conf /etc/nut/upsd.users 2>/dev/null || true +chown root:nut /etc/nut/notify.d && chmod 750 /etc/nut/notify.d +chown nut:nut /var/log/nut /var/run/nut + +# NUT daemons log via syslog(3), which is silently dropped when no syslog +# daemon is running (the container has no journald), leaving the NutWatch +# Logs tab empty. busybox syslogd is tiny, daemonizes on its own and captures +# everything to /var/log/messages, which the backend tails when journald is +# unavailable. +touch /var/log/messages +if command -v busybox >/dev/null 2>&1; then + busybox syslogd -O /var/log/messages +fi + +# Start USB drivers. This may fail if no USB device is present yet or if the +# configuration is intentionally empty; upsd/upsmon will keep retrying and the +# UI can start drivers later via upsdrvctl. +echo "[nutwatch] Starting NUT drivers..." +upsdrvctl start || true + +echo "[nutwatch] Starting supervisord..." +exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf diff --git a/scripts/docker/supervisord.conf b/scripts/docker/supervisord.conf new file mode 100644 index 0000000..c2733cb --- /dev/null +++ b/scripts/docker/supervisord.conf @@ -0,0 +1,41 @@ +[supervisord] +nodaemon=true +user=root +logfile=/var/log/supervisor/supervisord.log +pidfile=/var/run/supervisord.pid + +[unix_http_server] +file=/var/run/supervisor.sock +chmod=0700 + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisorctl] +serverurl=unix:///var/run/supervisor.sock + +[program:upsd] +# Run as root: the driver (see user = root in ups.conf) creates its control +# socket as root:root, which upsd running as `nut` cannot access. +command=/usr/sbin/upsd -F -u root +autostart=true +autorestart=true +stdout_logfile=/var/log/supervisor/upsd.log +stderr_logfile=/var/log/supervisor/upsd.err + +[program:upsmon] +# upsmon -F forks an unprivileged child that survives a plain restart; the +# wrapper traps supervisord's stop signal and brings the whole pair down. +command=/usr/local/bin/upsmon-wrapper +autostart=true +autorestart=true +stdout_logfile=/var/log/supervisor/upsmon.log +stderr_logfile=/var/log/supervisor/upsmon.err + +[program:nutwatch] +command=/opt/nutwatch/venv/bin/python /opt/nutwatch/app.py +directory=/opt/nutwatch +autostart=true +autorestart=true +stdout_logfile=/var/log/supervisor/nutwatch.log +stderr_logfile=/var/log/supervisor/nutwatch.err diff --git a/scripts/docker/systemctl-shim.sh b/scripts/docker/systemctl-shim.sh new file mode 100644 index 0000000..9d9f350 --- /dev/null +++ b/scripts/docker/systemctl-shim.sh @@ -0,0 +1,164 @@ +#!/bin/bash +# systemctl shim for NutWatch running inside a Docker container supervised by +# supervisord. Translates the systemctl calls issued by the backend into +# supervisorctl commands so the UI restart buttons keep working. + +set -euo pipefail + +map_service() { + case "$1" in + nut-server) echo "upsd" ;; + nut-monitor) echo "upsmon" ;; + nutwatch) echo "nutwatch" ;; + *) echo "" ;; + esac +} + +supervisor_running() { + supervisorctl status "$1" 2>/dev/null | grep -q "RUNNING" +} + +# Drivers run via upsdrvctl (not supervisord), so report on them by looking +# for a live driver PID file in the NUT state directory. +driver_running() { + local pidfile pid + for pidfile in /var/run/nut/*.pid; do + [[ -e "$pidfile" ]] || continue + case "$(basename "$pidfile")" in + upsd.pid | upsmon.pid) continue ;; + esac + pid="$(cat "$pidfile" 2>/dev/null || true)" + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + return 0 + fi + done + return 1 +} + +name_pid_alive() { + local pidfile pid + # Drivers name their pid file -.pid (e.g. usbhid-ups-myups.pid), + # so match the suffix too; keep the plain .pid form for older setups. + for pidfile in "/var/run/nut/${1}.pid" "/var/run/nut/"*"-${1}.pid"; do + [[ -e "$pidfile" ]] || continue + pid="$(cat "$pidfile" 2>/dev/null || true)" + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + return 0 + fi + done + return 1 +} + +# Dispatch driver lifecycle actions through upsdrvctl. Mirror systemctl: +# stopping an inactive driver is a success. +driver_action() { + local action="$1" svc="$2" name="" + case "$svc" in + nut-driver@*) name="${svc#nut-driver@}" ;; + esac + case "$action" in + stop) + if [[ -n "$name" ]]; then + name_pid_alive "$name" || return 0 + upsdrvctl stop "$name" + else + driver_running || return 0 + upsdrvctl stop + fi + ;; + restart) + # upsdrvctl has no restart verb; the stop is best-effort because the + # driver may not be running yet. + if [[ -n "$name" ]]; then + upsdrvctl stop "$name" 2>/dev/null || true + upsdrvctl start "$name" + else + upsdrvctl stop 2>/dev/null || true + upsdrvctl start + fi + ;; + start) + if [[ -n "$name" ]]; then + upsdrvctl start "$name" + else + upsdrvctl start + fi + ;; + esac +} + +is_active() { + local svc="$1" target + target="$(map_service "$svc")" + if [[ -n "$target" ]]; then + supervisor_running "$target" + return + fi + case "$svc" in + nut-driver | nut-driver@*) driver_running ;; + *) return 3 ;; + esac +} + +cmd="${1:-}" +shift || true + +case "$cmd" in +restart | start | stop) + # Propagate failures so the UI can report them instead of claiming success. + rc=0 + for svc in "$@"; do + target="$(map_service "$svc")" + if [[ -n "$target" ]]; then + supervisorctl "$cmd" "$target" || rc=$? + elif [[ "$svc" == "nut-driver" || "$svc" == nut-driver@* ]]; then + driver_action "$cmd" "$svc" || rc=$? + else + printf 'Unsupported service: %s\n' "$svc" >&2 + rc=4 + fi + done + exit "$rc" + ;; + +is-active) + # Mirror systemctl: print the state, exit 0 when active, 3 otherwise. + rc=3 + for svc in "$@"; do + if is_active "$svc"; then + echo "active" + rc=0 + else + echo "inactive" + fi + done + exit "$rc" + ;; + +status) + for svc in "$@"; do + if is_active "$svc"; then + state="active (running)" + else + state="inactive (dead)" + fi + echo "● ${svc}.service" + echo " Active: ${state}" + done + exit 0 + ;; + +reboot | poweroff) + echo "systemctl $cmd is not supported inside a container" >&2 + exit 1 + ;; + +*) + # Unknown command: fall back to the real systemctl if one exists. + if command -v /usr/bin/systemctl &>/dev/null; then + /usr/bin/systemctl "$cmd" "$@" + else + exit 0 + fi + ;; +esac diff --git a/scripts/docker/upsmon-wrapper.sh b/scripts/docker/upsmon-wrapper.sh new file mode 100644 index 0000000..5a6f74f --- /dev/null +++ b/scripts/docker/upsmon-wrapper.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# upsmon wrapper for supervisord. +# +# `upsmon -F` still forks: a root parent (the PID supervisord would track) and +# an unprivileged child running as `nut` in its own session (setsid). A plain +# `supervisorctl restart upsmon` signals only the tracked parent, so the child +# survives holding the PID file and every respawn dies instantly with +# "A previous upsmon instance is already running!", putting the program into +# FATAL. This wrapper stays in the foreground, traps termination signals, and +# brings the whole upsmon pair down so restarts are clean. + +set -euo pipefail + +PIDFILE="/var/run/nut/upsmon.pid" + +stop_upsmon() { + # The unprivileged child escapes the process group via setsid(), so signal + # by name. This container runs exactly one upsmon instance, launched by this + # wrapper, so any match is safe to kill. + pkill -x upsmon 2>/dev/null || true +} + +on_term() { + stop_upsmon + exit 0 +} +trap on_term TERM INT + +# Clean up orphans from a previous unclean stop so the new instance can claim +# its PID file. Give them a moment to exit on SIGTERM before escalating. +if pgrep -x upsmon >/dev/null 2>&1; then + stop_upsmon + for _ in $(seq 1 50); do + pgrep -x upsmon >/dev/null 2>&1 || break + sleep 0.1 + done + if pgrep -x upsmon >/dev/null 2>&1; then + pkill -9 -x upsmon 2>/dev/null || true + fi +fi + +# Drop a stale PID file left behind by a SIGKILLed instance, otherwise upsmon +# refuses to start. A live process holding it was handled above. +if [[ -f "$PIDFILE" ]]; then + old_pid="$(cat "$PIDFILE" 2>/dev/null || true)" + if [[ -z "$old_pid" ]] || ! kill -0 "$old_pid" 2>/dev/null; then + rm -f "$PIDFILE" + fi +fi + +/usr/sbin/upsmon -F & +upsmon_pid=$! + +# Surface upsmon's exit status to supervisord so autorestart works on crash. +# If the wrapper is signaled, the trap above runs and brings the pair down. +wait "$upsmon_pid" diff --git a/src/backend/routes/logs.py b/src/backend/routes/logs.py index b27e6a0..7072b94 100644 --- a/src/backend/routes/logs.py +++ b/src/backend/routes/logs.py @@ -1,27 +1,62 @@ +import os import select import subprocess from flask import Blueprint, Response, stream_with_context, request, jsonify from auth import require_auth +from services.system import _driver_status_units from utils import run_cmd logs_bp = Blueprint("logs", __name__) +# Syslog capture file used when journald is unavailable (e.g. inside the +# Docker container, where NUT daemons log via syslog()). +SYSLOG_FILE = os.environ.get("NUTWATCH_SYSLOG_FILE", "/var/log/messages") + + +def _journal_units() -> list[str]: + # The driver has no bare nut-driver.service on most NUT 2.8.x distros + # (drivers run as nut-driver@ instances), so resolve the unit names + # the same way service status does, or journalctl would drop driver logs. + return ["nut-server", "nut-monitor", *_driver_status_units()] + + +def _journal_available() -> bool: + """Return True when journalctl can serve the NUT unit logs. + + Containers usually run without journald; journalctl then prints + "No journal files were found" on stderr and returns nothing usable, + so the Logs tab would stay empty. In that case we fall back to + tailing the syslog capture file instead. + """ + rc, _, err = run_cmd(["journalctl", "--no-pager", "-n", "1"], timeout=10) + return rc == 0 and "No journal files" not in err + + +def _recent_command(lines: str, journal: bool) -> list: + if journal: + cmd = ["journalctl", "--no-pager", "-n", lines] + for unit in _journal_units(): + cmd += ["-u", unit] + return cmd + return ["tail", "-n", lines, SYSLOG_FILE] + + +def _stream_command(journal: bool) -> list: + if journal: + cmd = ["journalctl", "--no-pager", "-f", "-n", "0"] + for unit in _journal_units(): + cmd += ["-u", unit] + return cmd + return ["tail", "-F", "-n", "0", SYSLOG_FILE] + @logs_bp.route("/api/logs/stream") @require_auth def stream_logs(): proc = subprocess.Popen( - [ - "journalctl", - "-u", "nut-server", - "-u", "nut-monitor", - "-u", "nut-driver", - "-f", - "-n", "0", - "--no-pager", - ], + _stream_command(_journal_available()), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, @@ -57,15 +92,8 @@ def recent_logs(): lines = request.args.get("lines", "100") if not lines.isdigit(): lines = "100" - rc, out, err = run_cmd( - [ - "journalctl", - "-u", "nut-server", - "-u", "nut-monitor", - "-u", "nut-driver", - "-n", lines, - "--no-pager", - ], - timeout=30, - ) + journal = _journal_available() + if not journal and not os.path.exists(SYSLOG_FILE): + return jsonify({"returncode": 0, "stdout": "", "stderr": ""}) + rc, out, err = run_cmd(_recent_command(lines, journal), timeout=30) return jsonify({"returncode": rc, "stdout": out, "stderr": err}) \ No newline at end of file diff --git a/src/backend/services/system.py b/src/backend/services/system.py index 8e02038..06499e6 100644 --- a/src/backend/services/system.py +++ b/src/backend/services/system.py @@ -4,6 +4,7 @@ import time from config import NUT_DIR, ALLOWED_CONFIGS, IDENTIFIER_REGEX +from parsers.ups_conf import parse_ups_conf from utils import run_cmd, read_file, write_file, stop_driver_and_cleanup @@ -15,8 +16,62 @@ def restart_monitor(): return run_cmd(["systemctl", "restart", "nut-monitor"]) +def _ups_names() -> list[str]: + try: + content = read_file(os.path.join(NUT_DIR, "ups.conf")) + except FileNotFoundError: + return [] + return [entry["name"] for entry in parse_ups_conf(content)] + + +def _driver_status_units() -> list[str]: + # NUT's systemd unit set varies by distro/version. Current packaging + # (NUT 2.8.x, e.g. Ubuntu noble) starts drivers as nut-driver@ + # template instances managed by nut-driver-enumerator and ships no bare + # nut-driver.service; only some distros provide that wrapper unit. + rc, out, _ = run_cmd( + ["systemctl", "list-unit-files", "--no-legend", "--no-pager", "nut-driver.service"], + timeout=5, + ) + if rc == 0: + for line in out.splitlines(): + if line.split() and line.split()[0] == "nut-driver.service": + return ["nut-driver"] + return [f"nut-driver@{name}" for name in _ups_names()] + + +def _pidfile_driver_active() -> tuple[bool, str]: + # No systemd unit to ask (matches the Docker systemctl shim logic). + for base in ("/var/run/nut", "/run/nut"): + for pid_file in glob.glob(os.path.join(base, "*.pid")): + if os.path.basename(pid_file) in ("upsd.pid", "upsmon.pid"): + continue + try: + with open(pid_file, encoding="utf-8") as f: + pid = f.read().strip() + if pid.isdigit() and os.path.exists(f"/proc/{pid}"): + return True, "active" + except OSError: + pass + return False, "inactive" + + def restart_driver(): - return run_cmd(["systemctl", "restart", "nut-driver"]) + units = _driver_status_units() + if units: + rc = 0 + out = err = "" + for unit in units: + r, o, e = run_cmd(["systemctl", "restart", unit]) + rc = rc or r + out += o + err += e + return rc, out, err + # No systemd driver unit at all: fall back to upsdrvctl, which also works + # through the Docker systemctl shim. + rc1, out1, err1 = run_cmd(["upsdrvctl", "stop"], timeout=30) + rc2, out2, err2 = run_cmd(["upsdrvctl", "start"], timeout=30) + return rc1 or rc2, out1 + out2, err1 + err2 def restart_all(): @@ -49,7 +104,15 @@ def detailed_service_status(): services = ["nut-driver", "nut-server", "nut-monitor"] result = {} for svc in services: - rc, out, err = run_cmd(["systemctl", "is-active", svc], timeout=5) + if svc == "nut-driver": + units = _driver_status_units() + if not units: + active, state = _pidfile_driver_active() + result[svc] = {"active": active, "state": state} + continue + else: + units = [svc] + rc, out, err = run_cmd(["systemctl", "is-active", *units], timeout=5) state = (out or err).strip() result[svc] = {"active": rc == 0, "state": state} return result diff --git a/src/backend/tests/test_routes.py b/src/backend/tests/test_routes.py index 4f5b12e..c7991d5 100644 --- a/src/backend/tests/test_routes.py +++ b/src/backend/tests/test_routes.py @@ -112,6 +112,86 @@ def test_recent_logs(monkeypatch): assert data["returncode"] == 0 +def test_recent_logs_syslog_fallback(monkeypatch, tmp_path): + orig = routes.logs.run_cmd + log_file = tmp_path / "messages" + log_file.write_text("syslog line 1\nsyslog line 2\n") + + def fake_run_cmd(cmd, **kw): + if cmd[0] == "journalctl": + return (0, "-- No entries --\n", "No journal files were found.\n") + return orig(cmd, **kw) + + monkeypatch.setattr("routes.logs.SYSLOG_FILE", str(log_file)) + monkeypatch.setattr("routes.logs.run_cmd", fake_run_cmd) + app = _register_all(_make_app()) + with app.test_client() as c: + resp = c.get("/api/logs/recent") + assert resp.status_code == 200 + data = resp.get_json() + assert data["returncode"] == 0 + assert "syslog line 1" in data["stdout"] + assert "syslog line 2" in data["stdout"] + + +def test_recent_logs_syslog_fallback_missing_file(monkeypatch): + monkeypatch.setattr("routes.logs.SYSLOG_FILE", "/nonexistent/nutwatch/messages") + monkeypatch.setattr( + "routes.logs.run_cmd", + lambda cmd, **kw: (0, "-- No entries --\n", "No journal files were found.\n"), + ) + app = _register_all(_make_app()) + with app.test_client() as c: + resp = c.get("/api/logs/recent") + assert resp.status_code == 200 + data = resp.get_json() + assert data["returncode"] == 0 + assert data["stdout"] == "" + + +def test_journal_available_false_when_no_journal(monkeypatch): + monkeypatch.setattr( + "routes.logs.run_cmd", + lambda cmd, **kw: (0, "-- No entries --\n", "No journal files were found.\n"), + ) + assert routes.logs._journal_available() is False + + +def test_journal_available_true_with_journal(monkeypatch): + monkeypatch.setattr( + "routes.logs.run_cmd", + lambda cmd, **kw: (0, "Jun 01 00:00:00 host upsd[1]: started\n", ""), + ) + assert routes.logs._journal_available() is True + + +def test_journal_available_false_when_binary_missing(monkeypatch): + # utils.run_cmd returns (-1, "", "") when the binary is absent. + monkeypatch.setattr("routes.logs.run_cmd", lambda cmd, **kw: (-1, "", "journalctl: not found")) + assert routes.logs._journal_available() is False + + +def test_recent_command_uses_journalctl_when_available(monkeypatch): + monkeypatch.setattr("routes.logs._driver_status_units", lambda: ["nut-driver@ups"]) + cmd = routes.logs._recent_command("50", True) + assert cmd[0] == "journalctl" + assert "-n" in cmd and "50" in cmd + for unit in ("nut-server", "nut-monitor", "nut-driver@ups"): + assert "-u" in cmd and unit in cmd + + +def test_recent_command_uses_tail_in_fallback(monkeypatch): + monkeypatch.setattr("routes.logs.SYSLOG_FILE", "/var/log/messages") + cmd = routes.logs._recent_command("50", False) + assert cmd == ["tail", "-n", "50", "/var/log/messages"] + + +def test_stream_command_journal_and_fallback(monkeypatch): + assert routes.logs._stream_command(True)[0] == "journalctl" + monkeypatch.setattr("routes.logs.SYSLOG_FILE", "/var/log/messages") + assert routes.logs._stream_command(False) == ["tail", "-F", "-n", "0", "/var/log/messages"] + + # ── System routes ───────────────────────────────────────────────────── def test_get_config_route(monkeypatch): diff --git a/src/backend/tests/test_services_system.py b/src/backend/tests/test_services_system.py index ae5cb58..534ca29 100644 --- a/src/backend/tests/test_services_system.py +++ b/src/backend/tests/test_services_system.py @@ -1,3 +1,5 @@ +import os + import pytest @@ -22,13 +24,86 @@ def test_restart_monitor(monkeypatch): assert rc == 0 -def test_restart_driver(monkeypatch): +def test_restart_driver_bare_unit(monkeypatch): calls = [] - monkeypatch.setattr("services.system.run_cmd", lambda cmd, **kw: (calls.append(cmd), _fake_rc(0))[1]) + def fake_run(cmd, **kw): + calls.append(cmd) + if "list-unit-files" in cmd: + return _fake_rc(0, "nut-driver.service enabled") + return _fake_rc(0) + monkeypatch.setattr("services.system.run_cmd", fake_run) + from services.system import restart_driver + rc, _, _ = restart_driver() + assert rc == 0 + assert calls[-1] == ["systemctl", "restart", "nut-driver"] + + +def test_restart_driver_template_units(monkeypatch): + calls = [] + def fake_run(cmd, **kw): + calls.append(cmd) + if "list-unit-files" in cmd: + return _fake_rc(1, "0 unit files listed.") + return _fake_rc(0) + monkeypatch.setattr("services.system.run_cmd", fake_run) + monkeypatch.setattr("services.system._ups_names", lambda: ["ups1", "ups2"]) + from services.system import restart_driver + rc, _, _ = restart_driver() + assert rc == 0 + assert ["systemctl", "restart", "nut-driver@ups1"] in calls + assert ["systemctl", "restart", "nut-driver@ups2"] in calls + + +def test_restart_driver_upsdrvctl_fallback(monkeypatch): + calls = [] + def fake_run(cmd, **kw): + calls.append(cmd) + if "list-unit-files" in cmd: + return _fake_rc(1, "0 unit files listed.") + return _fake_rc(0) + monkeypatch.setattr("services.system.run_cmd", fake_run) + monkeypatch.setattr("services.system._ups_names", lambda: []) + from services.system import restart_driver + rc, _, _ = restart_driver() + assert rc == 0 + assert ["upsdrvctl", "stop"] in calls + assert ["upsdrvctl", "start"] in calls + + +def test_restart_driver_ignores_template_unit_row(monkeypatch): + # list-unit-files may exit 0 while listing only the nut-driver@.service + # template; only a row whose first field is exactly nut-driver.service + # selects the bare unit. + calls = [] + def fake_run(cmd, **kw): + calls.append(cmd) + if "list-unit-files" in cmd: + return _fake_rc(0, "nut-driver@.service enabled enabled") + return _fake_rc(0) + monkeypatch.setattr("services.system.run_cmd", fake_run) + monkeypatch.setattr("services.system._ups_names", lambda: ["ups1"]) from services.system import restart_driver rc, _, _ = restart_driver() assert rc == 0 - assert any("nut-driver" in c for c in calls) + assert ["systemctl", "restart", "nut-driver@ups1"] in calls + + +def test_restart_driver_upsdrvctl_stop_failure_propagates(monkeypatch): + calls = [] + def fake_run(cmd, **kw): + calls.append(cmd) + if "list-unit-files" in cmd: + return _fake_rc(1, "0 unit files listed.") + if cmd == ["upsdrvctl", "stop"]: + return _fake_rc(1, "", "stop failed") + return _fake_rc(0) + monkeypatch.setattr("services.system.run_cmd", fake_run) + monkeypatch.setattr("services.system._ups_names", lambda: []) + from services.system import restart_driver + rc, _, err = restart_driver() + assert rc == 1 + assert "stop failed" in err + assert ["upsdrvctl", "start"] in calls def test_restart_all(monkeypatch): @@ -56,16 +131,53 @@ def fake_run(cmd, **kw): def test_detailed_service_status(monkeypatch): - states = iter(["active", "inactive", "active"]) def fake_run(cmd, **kw): - state = next(states) - rc = 0 if state == "active" else 3 - return _fake_rc(rc, state) + if "list-unit-files" in cmd: + return _fake_rc(0, "nut-driver.service enabled") + if cmd[:2] == ["systemctl", "is-active"]: + if "nut-driver" in cmd[2:]: + return _fake_rc(0, "active") + if "nut-server" in cmd[2:]: + return _fake_rc(3, "inactive") + return _fake_rc(0, "active") + return _fake_rc(0) monkeypatch.setattr("services.system.run_cmd", fake_run) from services.system import detailed_service_status result = detailed_service_status() assert result["nut-driver"]["active"] is True assert result["nut-server"]["active"] is False + assert result["nut-monitor"]["active"] is True + + +def test_detailed_service_status_template_instances(monkeypatch): + def fake_run(cmd, **kw): + if "list-unit-files" in cmd: + return _fake_rc(1, "0 unit files listed.") + if cmd[:2] == ["systemctl", "is-active"]: + if "nut-driver" in cmd[2:]: + assert cmd[2:] == ["nut-driver@ups"] + return _fake_rc(0, "active") + return _fake_rc(0) + monkeypatch.setattr("services.system.run_cmd", fake_run) + monkeypatch.setattr("services.system._ups_names", lambda: ["ups"]) + from services.system import detailed_service_status + result = detailed_service_status() + assert result["nut-driver"]["active"] is True + + +def test_detailed_service_status_pidfile_fallback(monkeypatch, tmp_path): + def fake_run(cmd, **kw): + if "list-unit-files" in cmd: + return _fake_rc(1, "0 unit files listed.") + return _fake_rc(0) + monkeypatch.setattr("services.system.run_cmd", fake_run) + monkeypatch.setattr("services.system._ups_names", lambda: []) + monkeypatch.setattr("services.system.glob.glob", lambda p: [str(tmp_path / "ups.pid")]) + (tmp_path / "ups.pid").write_text(str(os.getpid())) + from services.system import detailed_service_status + result = detailed_service_status() + assert result["nut-driver"]["active"] is True + assert result["nut-driver"]["state"] == "active" def test_service_status(monkeypatch):