From e27052d09a384535bc460d7aa4f164d456514fa7 Mon Sep 17 00:00:00 2001 From: xavierk Date: Tue, 15 Sep 2026 00:15:24 +0530 Subject: [PATCH] Implement runit support for Fenris monitoring (issue #84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deliver end-to-end native monitoring path under runit with existing CLI/TUI controls and truthful status, preserving systemd behavior. Changes: - Add init system abstraction layer (src/fenris/init_system.py) that detects systemd vs runit and provides unified interface for timer control, on-demand collection, and service state queries - Create runit service files (units/runit/) with completion-relative 5-minute cadence, 2-minute boot delay, bounded execution (90s), no catch-up, and serialized runs via flock - Update monitor.py to use abstraction layer instead of direct systemctl - Update status.py to use abstraction layer for service state queries - Update all packaging scripts (deb, rpm) for init-system-aware setup - Update Makefile to install runit service files alongside systemd units - Add 46 tests for init system abstraction layer Spec: ADR 0008, §8.4, §8.5, §8.6, §8.7, §8.8 Closes #84 Co-authored-by: CommandCodeBot --- Makefile | 12 + packaging/postinst.sh | 68 ++- packaging/postrm.sh | 15 +- packaging/prerm.sh | 17 +- packaging/rpm/post.sh | 70 +-- packaging/rpm/postun.sh | 15 +- packaging/rpm/preun.sh | 17 +- packaging/stage.sh | 6 + src/fenris/init_system.py | 479 +++++++++++++++++++++ src/fenris/monitor.py | 67 +-- src/fenris/status.py | 106 +---- tests/test_init_system.py | 659 +++++++++++++++++++++++++++++ tests/test_monitor.py | 37 +- units/runit/fenris-collect/log/run | 9 + units/runit/fenris-collect/run | 40 ++ 15 files changed, 1390 insertions(+), 227 deletions(-) create mode 100644 src/fenris/init_system.py create mode 100644 tests/test_init_system.py create mode 100755 units/runit/fenris-collect/log/run create mode 100755 units/runit/fenris-collect/run diff --git a/Makefile b/Makefile index 67f8bea..3e5f62e 100644 --- a/Makefile +++ b/Makefile @@ -93,6 +93,12 @@ install: check-python check-smartctl dist/fenris-*.whl @sudo install -m 0644 units/fenris-collect.service $(UNIT_DIR)/ @sudo systemctl daemon-reload + @echo "=== Installing runit service files (dormant — not enabled) ===" + @sudo install -d -m 0755 /etc/sv/fenris-collect/log + @sudo install -m 0755 units/runit/fenris-collect/run /etc/sv/fenris-collect/run + @sudo install -m 0755 units/runit/fenris-collect/log/run /etc/sv/fenris-collect/log/run + @sudo touch /etc/sv/fenris-collect/down + @echo "=== Installing polkit policy ===" @sudo install -m 0644 polkit/com.bongbetic.fenris.monitor.policy $(POLKIT_DIR)/ @@ -143,6 +149,9 @@ upgrade: dist/fenris-*.whl @echo "=== Syncing units against manifest ===" @sudo install -m 0644 units/fenris-collect.timer $(UNIT_DIR)/ @sudo install -m 0644 units/fenris-collect.service $(UNIT_DIR)/ + @sudo install -d -m 0755 /etc/sv/fenris-collect/log + @sudo install -m 0755 units/runit/fenris-collect/run /etc/sv/fenris-collect/run + @sudo install -m 0755 units/runit/fenris-collect/log/run /etc/sv/fenris-collect/log/run @sudo install -m 0644 polkit/com.bongbetic.fenris.monitor.policy $(POLKIT_DIR)/ @sudo install -m 0755 scripts/fenris $(BIN_DIR)/fenris @sudo install -m 0755 src/fenris/monitor.py $(LIBEXEC_DIR)/fenris-monitor @@ -196,6 +205,9 @@ uninstall: @sudo systemctl stop fenris-collect.timer 2>/dev/null || true @sudo systemctl disable fenris-collect.timer 2>/dev/null || true @sudo systemctl daemon-reload + @-sudo rm -f /var/service/fenris-collect 2>/dev/null || true + @-sudo rm -rf /etc/sv/fenris-collect 2>/dev/null || true + @-sudo rm -rf /var/log/fenris-collect 2>/dev/null || true @echo "=== Removing installed files (preserving config and store) ===" @-rm -f $(BIN_DIR)/fenris diff --git a/packaging/postinst.sh b/packaging/postinst.sh index a90002a..54a0ba0 100755 --- a/packaging/postinst.sh +++ b/packaging/postinst.sh @@ -12,10 +12,17 @@ STORE_BAK="${STORE_DIR}/observations.db.bak" RUNTIME_PYTHON="/usr/bin/python3" VENDOR_DIR="/opt/fenris/vendor" +# Detect init system +if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then + INIT_SYSTEM="systemd" +else + INIT_SYSTEM="runit" +fi + case "${1:-}" in configure) if [ -n "${2:-}" ]; then - # Upgrade — snapshot, migration, daemon-reload, conditional timer restart + # Upgrade — snapshot, migration, init-system-aware reload if [ -f "${STORE_DB}" ]; then cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true fi @@ -27,29 +34,44 @@ n = migrate_to_latest(Path('${STORE_DB}')) print(f'Fenris migration: {n} step(s) applied') if n else None " 2>&1 || echo "Fenris: migration skipped (store not yet initialized)" fi - # Capture running unit content BEFORE daemon-reload (spec §7) - RUNNING_UNITS="" - for unit in fenris-collect.timer; do - if systemctl is-active --quiet "${unit}" 2>/dev/null; then - RUNNING_UNITS="${RUNNING_UNITS} ${unit}" - fi - done - systemctl daemon-reload 2>/dev/null || true - # Restart timer only if unit contents changed AND active - for unit in ${RUNNING_UNITS}; do - OLD_CONTENT="$(mktemp)" - NEW_PATH="/usr/lib/systemd/system/${unit}" - systemctl cat "${unit}" > "${OLD_CONTENT}" 2>/dev/null || true - if ! diff -q "${OLD_CONTENT}" "${NEW_PATH}" > /dev/null 2>&1; then - systemctl restart "${unit}" 2>/dev/null || true - fi - rm -f "${OLD_CONTENT}" - done + if [ "${INIT_SYSTEM}" = "systemd" ]; then + # Capture running unit content BEFORE daemon-reload (spec §7) + RUNNING_UNITS="" + for unit in fenris-collect.timer; do + if systemctl is-active --quiet "${unit}" 2>/dev/null; then + RUNNING_UNITS="${RUNNING_UNITS} ${unit}" + fi + done + systemctl daemon-reload 2>/dev/null || true + # Restart timer only if unit contents changed AND active + for unit in ${RUNNING_UNITS}; do + OLD_CONTENT="$(mktemp)" + NEW_PATH="/usr/lib/systemd/system/${unit}" + systemctl cat "${unit}" > "${OLD_CONTENT}" 2>/dev/null || true + if ! diff -q "${OLD_CONTENT}" "${NEW_PATH}" > /dev/null 2>&1; then + systemctl restart "${unit}" 2>/dev/null || true + fi + rm -f "${OLD_CONTENT}" + done + fi + fi + # Fresh install: init-system-specific setup + if [ "${INIT_SYSTEM}" = "systemd" ]; then + systemd-sysusers || true + systemd-tmpfiles --create || true + systemctl daemon-reload || true + else + # runit: create group, set directory permissions + groupadd -f fenris + install -d -o root -g fenris -m 2750 "${STORE_DIR}" 2>/dev/null || true + # Mark runit service as dormant (down) for fresh install + if [ -d /etc/sv/fenris-collect ] && [ ! -e /var/service/fenris-collect ]; then + touch /etc/sv/fenris-collect/down + fi + # Ensure log directory exists + mkdir -p /var/log/fenris-collect + chown fenris:fenris /var/log/fenris-collect 2>/dev/null || true fi - # sysusers, tmpfiles, daemon-reload (both fresh install and upgrade) - systemd-sysusers || true - systemd-tmpfiles --create || true - systemctl daemon-reload || true ;; abort-upgrade|abort-install|disappear) ;; diff --git a/packaging/postrm.sh b/packaging/postrm.sh index 5550254..0f6950b 100755 --- a/packaging/postrm.sh +++ b/packaging/postrm.sh @@ -5,6 +5,13 @@ # postrm purge (after conffiles and config removed) set -eu +# Detect init system +if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then + INIT_SYSTEM="systemd" +else + INIT_SYSTEM="runit" +fi + case "${1:-}" in purge) rm -rf /etc/fenris @@ -16,4 +23,10 @@ case "${1:-}" in remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear) ;; esac -systemctl daemon-reload 2>/dev/null || true +if [ "${INIT_SYSTEM}" = "systemd" ]; then + systemctl daemon-reload 2>/dev/null || true +else + # runit: clean up service directory and log + rm -rf /etc/sv/fenris-collect 2>/dev/null || true + rm -rf /var/log/fenris-collect 2>/dev/null || true +fi diff --git a/packaging/prerm.sh b/packaging/prerm.sh index 3642006..89c873d 100755 --- a/packaging/prerm.sh +++ b/packaging/prerm.sh @@ -5,14 +5,27 @@ # prerm upgrade (old version about to be replaced) set -eu +# Detect init system +if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then + INIT_SYSTEM="systemd" +else + INIT_SYSTEM="runit" +fi + case "${1:-}" in remove) # Sanctioned disable — close monitoring period (spec §7) if [ -x /usr/libexec/fenris/fenris-monitor ]; then /usr/libexec/fenris/fenris-monitor disable --now 2>/dev/null || true fi - systemctl stop fenris-collect.timer 2>/dev/null || true - systemctl disable fenris-collect.timer 2>/dev/null || true + if [ "${INIT_SYSTEM}" = "systemd" ]; then + systemctl stop fenris-collect.timer 2>/dev/null || true + systemctl disable fenris-collect.timer 2>/dev/null || true + else + # runit: remove the service symlink + rm -f /var/service/fenris-collect + touch /etc/sv/fenris-collect/down 2>/dev/null || true + fi ;; upgrade) # Never interrupt monitoring on upgrade diff --git a/packaging/rpm/post.sh b/packaging/rpm/post.sh index 085364e..a6071e4 100755 --- a/packaging/rpm/post.sh +++ b/packaging/rpm/post.sh @@ -8,13 +8,33 @@ STORE_BAK="${STORE_DIR}/observations.db.bak" RUNTIME_PYTHON="/usr/bin/python3" VENDOR_DIR="/opt/fenris/vendor" +# Detect init system +if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then + INIT_SYSTEM="systemd" +else + INIT_SYSTEM="runit" +fi + if [ "$1" -eq 1 ]; then # Fresh install - systemd-sysusers || true - systemd-tmpfiles --create || true - systemctl daemon-reload || true + if [ "${INIT_SYSTEM}" = "systemd" ]; then + systemd-sysusers || true + systemd-tmpfiles --create || true + systemctl daemon-reload || true + else + # runit: create group, set directory permissions + groupadd -f fenris + install -d -o root -g fenris -m 2750 "${STORE_DIR}" 2>/dev/null || true + # Mark runit service as dormant (down) for fresh install + if [ -d /etc/sv/fenris-collect ] && [ ! -e /var/service/fenris-collect ]; then + touch /etc/sv/fenris-collect/down + fi + # Ensure log directory exists + mkdir -p /var/log/fenris-collect + chown fenris:fenris /var/log/fenris-collect 2>/dev/null || true + fi elif [ "$1" -ge 2 ]; then - # Upgrade — snapshot, migration, daemon-reload, conditional timer restart + # Upgrade — snapshot, migration, init-system-aware reload if [ -f "${STORE_DB}" ]; then cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true fi @@ -26,24 +46,26 @@ n = migrate_to_latest(Path('${STORE_DB}')) print(f'Fenris migration: {n} step(s) applied') if n else None " 2>&1 || echo "Fenris: migration skipped (store not yet initialized)" fi - # Capture running unit content BEFORE daemon-reload (spec §7) - RUNNING_UNITS="" - for unit in fenris-collect.timer; do - if systemctl is-active --quiet "${unit}" 2>/dev/null; then - RUNNING_UNITS="${RUNNING_UNITS} ${unit}" - fi - done - systemctl daemon-reload 2>/dev/null || true - # Restart timer only if unit contents changed AND active - for unit in ${RUNNING_UNITS}; do - OLD_CONTENT="$(mktemp)" - NEW_PATH="/usr/lib/systemd/system/${unit}" - systemctl cat "${unit}" > "${OLD_CONTENT}" 2>/dev/null || true - if ! diff -q "${OLD_CONTENT}" "${NEW_PATH}" > /dev/null 2>&1; then - systemctl restart "${unit}" 2>/dev/null || true - fi - rm -f "${OLD_CONTENT}" - done - # Re-apply placement modes (store dir group access, issue #54) - systemd-tmpfiles --create || true + if [ "${INIT_SYSTEM}" = "systemd" ]; then + # Capture running unit content BEFORE daemon-reload (spec §7) + RUNNING_UNITS="" + for unit in fenris-collect.timer; do + if systemctl is-active --quiet "${unit}" 2>/dev/null; then + RUNNING_UNITS="${RUNNING_UNITS} ${unit}" + fi + done + systemctl daemon-reload 2>/dev/null || true + # Restart timer only if unit contents changed AND active + for unit in ${RUNNING_UNITS}; do + OLD_CONTENT="$(mktemp)" + NEW_PATH="/usr/lib/systemd/system/${unit}" + systemctl cat "${unit}" > "${OLD_CONTENT}" 2>/dev/null || true + if ! diff -q "${OLD_CONTENT}" "${NEW_PATH}" > /dev/null 2>&1; then + systemctl restart "${unit}" 2>/dev/null || true + fi + rm -f "${OLD_CONTENT}" + done + # Re-apply placement modes (store dir group access, issue #54) + systemd-tmpfiles --create || true + fi fi diff --git a/packaging/rpm/postun.sh b/packaging/rpm/postun.sh index bf571cd..255ce9a 100755 --- a/packaging/rpm/postun.sh +++ b/packaging/rpm/postun.sh @@ -2,6 +2,13 @@ # RPM %postun — post-uninstall scriptlet (spec §7). set -eu +# Detect init system +if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then + INIT_SYSTEM="systemd" +else + INIT_SYSTEM="runit" +fi + if [ "$1" -eq 0 ]; then # Package fully erased — remove config, store, group rm -rf /etc/fenris @@ -10,4 +17,10 @@ if [ "$1" -eq 0 ]; then groupdel fenris 2>/dev/null || true fi fi -systemctl daemon-reload 2>/dev/null || true +if [ "${INIT_SYSTEM}" = "systemd" ]; then + systemctl daemon-reload 2>/dev/null || true +else + # runit: clean up service directory and log + rm -rf /etc/sv/fenris-collect 2>/dev/null || true + rm -rf /var/log/fenris-collect 2>/dev/null || true +fi diff --git a/packaging/rpm/preun.sh b/packaging/rpm/preun.sh index 13e2f79..f07a3cd 100755 --- a/packaging/rpm/preun.sh +++ b/packaging/rpm/preun.sh @@ -2,12 +2,25 @@ # RPM %preun — pre-uninstall scriptlet (spec §7). set -eu +# Detect init system +if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then + INIT_SYSTEM="systemd" +else + INIT_SYSTEM="runit" +fi + if [ "$1" -eq 0 ]; then # Package is being erased — sanctioned disable (spec §7) if [ -x /usr/libexec/fenris/fenris-monitor ]; then /usr/libexec/fenris/fenris-monitor disable --now 2>/dev/null || true fi - systemctl stop fenris-collect.timer 2>/dev/null || true - systemctl disable fenris-collect.timer 2>/dev/null || true + if [ "${INIT_SYSTEM}" = "systemd" ]; then + systemctl stop fenris-collect.timer 2>/dev/null || true + systemctl disable fenris-collect.timer 2>/dev/null || true + else + # runit: remove the service symlink + rm -f /var/service/fenris-collect + touch /etc/sv/fenris-collect/down 2>/dev/null || true + fi fi # On upgrade ($1 -ge 1): do nothing diff --git a/packaging/stage.sh b/packaging/stage.sh index 977f099..fec735c 100755 --- a/packaging/stage.sh +++ b/packaging/stage.sh @@ -74,6 +74,12 @@ mkdir -p "${STAGE_DIR}/usr/lib/systemd/system" install -m 0644 "${REPO_ROOT}/units/fenris-collect.timer" "${STAGE_DIR}/usr/lib/systemd/system/" install -m 0644 "${REPO_ROOT}/units/fenris-collect.service" "${STAGE_DIR}/usr/lib/systemd/system/" +# --- runit service files --- +echo " Installing runit service files ..." +mkdir -p "${STAGE_DIR}/etc/sv/fenris-collect/log" +install -m 0755 "${REPO_ROOT}/units/runit/fenris-collect/run" "${STAGE_DIR}/etc/sv/fenris-collect/run" +install -m 0755 "${REPO_ROOT}/units/runit/fenris-collect/log/run" "${STAGE_DIR}/etc/sv/fenris-collect/log/run" + # --- polkit policy --- echo " Installing polkit policy ..." mkdir -p "${STAGE_DIR}/usr/share/polkit-1/actions" diff --git a/src/fenris/init_system.py b/src/fenris/init_system.py new file mode 100644 index 0000000..e177acb --- /dev/null +++ b/src/fenris/init_system.py @@ -0,0 +1,479 @@ +"""Init system abstraction for Fenris monitoring (issue #84). + +Detects the active init system (systemd or runit) and provides a unified +interface for timer/collection control and service-state queries. This keeps +the privileged helper and status composition init-system agnostic without +introducing a generalized plugin framework. + +Design: ADR 0008 — keep service-specific operations behind a cohesive +responsibility shared by the privileged control path and read-only status +composition. + +Runit service layout: + /etc/sv/fenris-collect/run — scheduler (sleep 120; loop { collect; sleep 300 }) + /etc/sv/fenris-collect/log/run — logger to /var/log/fenris-collect/ + /var/service/fenris-collect — symlink to enable + /etc/sv/fenris-collect/down — marker for dormant install + +Runit guarantees: + - Completion-relative 5-minute cadence (sleep 300 after each collect) + - Initial 2-minute boot delay (sleep 120 before first collect) + - Bounded execution (90s timeout via timeout(1)) + - No catch-up (service sleeps fixed interval, no Persistent= flag) + - Serialized scheduled runs (runsv does not restart until exit) + - Serialized on-demand (flock serializes fenris-collect execution) + +Spec: §8.4, §8.5, §8.6, §8.7, §8.8, ADR 0008 +""" +import os +import shutil +import subprocess +import sys +from enum import Enum +from pathlib import Path +from typing import Any, Dict, Optional + + +# --------------------------------------------------------------------------- +# Init system detection +# --------------------------------------------------------------------------- + +class InitSystem(Enum): + SYSTEMD = "systemd" + RUNIT = "runit" + + +FENRIS_SV_DIR = Path("/etc/sv/fenris-collect") +FENRIS_SERVICE_LINK = Path("/var/service/fenris-collect") +FENRIS_LOG_DIR = Path("/var/log/fenris-collect") +COLLECT_TIMEOUT_S = 90 + + +def detect_init_system() -> InitSystem: + """Detect the active init system. + + Checks for systemd first (PID 1 is systemd or /run/systemd/system exists), + then falls back to runit (PID 1 is runsv or /etc/sv exists). + """ + # systemd detection: /run/systemd/system exists when systemd is PID 1 + if Path("/run/systemd/system").exists(): + return InitSystem.SYSTEMD + + # Check PID 1 name + try: + pid1_comm = Path("/proc/1/comm").read_text().strip() + if pid1_comm == "systemd": + return InitSystem.SYSTEMD + if pid1_comm in ("runsv", "runsvdir"): + return InitSystem.RUNIT + except OSError: + pass + + # Fallback: check for /etc/sv (Void Linux default) + if Path("/etc/sv").is_dir(): + return InitSystem.RUNIT + + # Default to systemd (existing behavior) + return InitSystem.SYSTEMD + + +def get_init_system() -> InitSystem: + """Get the detected init system (cached).""" + if not hasattr(get_init_system, "_cached"): + get_init_system._cached = detect_init_system() + return get_init_system._cached + + +def reset_init_system_cache() -> None: + """Reset the cached init system detection (for testing).""" + if hasattr(get_init_system, "_cached"): + delattr(get_init_system, "_cached") + + +# --------------------------------------------------------------------------- +# systemd backend +# --------------------------------------------------------------------------- + +def _systemd_enable(now: bool) -> None: + """Enable and optionally start the systemd timer.""" + cmd = ["systemctl", "enable"] + if now: + cmd.append("--now") + cmd.append("fenris-collect.timer") + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print("Error enabling timer:", result.stderr, file=sys.stderr) + sys.exit(1) + print("Timer enabled" + (" and started" if now else "")) + + +def _systemd_disable(now: bool) -> None: + """Disable and optionally stop the systemd timer.""" + cmd = ["systemctl", "disable"] + if now: + cmd.append("--now") + cmd.append("fenris-collect.timer") + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print("Error disabling timer:", result.stderr, file=sys.stderr) + sys.exit(1) + print("Timer disabled" + (" and stopped" if now else "")) + + +def _systemd_collect() -> None: + """Trigger on-demand collection via systemd (blocking).""" + result = subprocess.run( + ["systemctl", "start", "fenris-collect.service"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + print("Collection completed successfully") + else: + print("Collection failed:", result.stderr, file=sys.stderr) + sys.exit(1) + + +def _systemctl_show(unit: str, *properties: str) -> Dict[str, str]: + """Query systemctl show for specific properties.""" + try: + result = subprocess.run( + ["systemctl", "show", unit, "--property=" + ",".join(properties)], + capture_output=True, text=True, timeout=5, + ) + if result.returncode != 0: + return {} + out = {} + for line in result.stdout.splitlines(): + if "=" in line: + key, _, value = line.partition("=") + out[key.strip()] = value.strip() + return out + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return {} + + +def _systemd_query_state() -> Dict[str, Any]: + """Query systemd for the four separate service facts.""" + from datetime import datetime, timezone + + timer_props = _systemctl_show( + "fenris-collect.timer", + "UnitFileState", "ActiveState", "LastTriggerUSec", + ) + service_props = _systemctl_show( + "fenris-collect.service", + "ActiveState", "ExecMainStatus", "ExecMainExitTimestamp", + ) + + boot_enabled_str = timer_props.get("UnitFileState", "") + boot_enabled = boot_enabled_str == "enabled" + + active_state = timer_props.get("ActiveState", "inactive") + timer_active = active_state == "active" + + last_collect_ok = None + last_collect_age_s = None + last_collect_reason = None + + last_trigger = timer_props.get("LastTriggerUSec", "") + if last_trigger and last_trigger != "n/a": + try: + trigger_dt = datetime.fromisoformat(last_trigger.replace("Z", "+00:00")) + now = datetime.now(timezone.utc) + last_collect_age_s = int((now - trigger_dt).total_seconds()) + except (ValueError, TypeError): + pass + + exec_status = service_props.get("ExecMainStatus", "") + if exec_status: + try: + exit_code = int(exec_status) + last_collect_ok = exit_code == 0 + if exit_code != 0: + last_collect_reason = "exit code %d" % exit_code + except (ValueError, TypeError): + pass + + return { + "boot_enabled": boot_enabled, + "timer_active": timer_active, + "last_collect_ok": last_collect_ok, + "last_collect_age_s": last_collect_age_s, + "last_collect_reason": last_collect_reason, + } + + +def _systemd_journal_hint(lines: int = 5) -> Optional[str]: + """Get the last N journal lines for fenris-collect.service.""" + try: + result = subprocess.run( + ["journalctl", "-u", "fenris-collect.service", + "--no-pager", "-n", str(lines), "--output=short-iso"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode != 0 or not result.stdout.strip(): + return None + return result.stdout.strip() + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return None + + +# --------------------------------------------------------------------------- +# runit backend +# --------------------------------------------------------------------------- + +def _runit_enable(_now: bool) -> None: + """Enable the runit service by creating a symlink. + + runit activates the service immediately when the symlink appears. + If the service is already enabled but stopped (e.g., via `sv stop`), + restart it when `now=True`. + """ + if FENRIS_SERVICE_LINK.exists(): + # Already enabled — check if we need to restart + if _now and not _runit_is_running(): + # Service is stopped but enabled — restart via sv + try: + subprocess.run( + ["sv", "restart", "fenris-collect"], + capture_output=True, text=True, timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + print("Service already enabled (idempotent)") + return + + # Remove the 'down' file if present (dormant install marker) + down_file = FENRIS_SV_DIR / "down" + if down_file.exists(): + down_file.unlink() + + FENRIS_SERVICE_LINK.symlink_to(FENRIS_SV_DIR) + print("Service enabled") + + +def _runit_disable(_now: bool) -> None: + """Disable the runit service by removing the symlink. + + runit stops the service immediately when the symlink is removed. + """ + if not FENRIS_SERVICE_LINK.exists(): + print("Service already disabled (idempotent)") + return + + FENRIS_SERVICE_LINK.unlink() + # Place 'down' file to mark as intentionally disabled + (FENRIS_SV_DIR / "down").touch() + print("Service disabled") + + +def _runit_collect() -> None: + """Trigger on-demand collection via direct execution with flock. + + Serializes against the scheduler using the same lock file. + The timeout(1) command enforces bounded execution. + """ + lock_path = Path("/var/lib/fenris/fenris-collect.lock") + collect_script = Path("/usr/libexec/fenris/fenris-collect") + fallback_script = Path(__file__).parent.parent.parent / "src" / "fenris" / "collect.py" + + if collect_script.exists(): + script = str(collect_script) + elif fallback_script.exists(): + script = str(fallback_script) + else: + print("Error: fenris-collect script not found", file=sys.stderr) + sys.exit(1) + + try: + result = subprocess.run( + ["flock", "--nonblock", str(lock_path), + "timeout", str(COLLECT_TIMEOUT_S), "nice", "ionice", "-c3", + sys.executable, script], + capture_output=True, + text=True, + ) + if result.returncode == 0: + print("Collection completed successfully") + elif result.returncode == 124: + print("Collection timed out after %ds" % COLLECT_TIMEOUT_S, file=sys.stderr) + sys.exit(1) + else: + # exit code 1 from flock means lock is held (scheduled run in progress) + if result.returncode == 1 and "Resource temporarily unavailable" in result.stderr: + print("Collection already in progress (serialized)", file=sys.stderr) + sys.exit(1) + print("Collection failed:", result.stderr, file=sys.stderr) + sys.exit(1) + except FileNotFoundError: + print("Error: flock/timeout not found", file=sys.stderr) + sys.exit(1) + + +def _runit_is_enabled() -> bool: + """Check if the runit service is enabled (symlink exists).""" + return FENRIS_SERVICE_LINK.exists() + + +def _runit_is_running() -> bool: + """Check if the runit service is currently running. + + Looks for a 'supervise/pid' file in the service directory. + """ + pid_file = FENRIS_SV_DIR / "supervise" / "pid" + if not pid_file.exists(): + return False + try: + pid = int(pid_file.read_text().strip()) + # Check if the process is alive + os.kill(pid, 0) + return True + except (ValueError, OSError): + return False + + +def _runit_query_state() -> Dict[str, Any]: + """Query runit for the four separate service facts. + + Checks: boot_enabled (symlink), timer_active (running), last_collect + (store-based), freshness (store-based). + """ + from datetime import datetime, timezone + from pathlib import Path + + boot_enabled = _runit_is_enabled() + timer_active = _runit_is_running() + + last_collect_ok = None + last_collect_age_s = None + last_collect_reason = None + + # Try to get last collection info from the store + store_path = Path("/var/lib/fenris/observations.db") + if store_path.exists(): + try: + import sqlite3 + conn = sqlite3.connect("file:%s?mode=ro" % store_path, uri=True) + conn.row_factory = sqlite3.Row + + # Get newest sample + cursor = conn.execute( + "SELECT ts FROM samples ORDER BY id DESC LIMIT 1" + ) + row = cursor.fetchone() + if row and row[0]: + try: + ts = datetime.fromisoformat(row[0]) + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + now = datetime.now(timezone.utc) + last_collect_age_s = int((now - ts).total_seconds()) + last_collect_ok = True + except (ValueError, TypeError): + pass + + # Check for failed collections via monitoring_periods + cursor = conn.execute( + "SELECT end_cause FROM monitoring_periods " + "WHERE ended_at IS NOT NULL ORDER BY ended_at DESC LIMIT 1" + ) + row = cursor.fetchone() + if row and row[0] and row[0] not in ("user_disabled", None): + last_collect_reason = row[0] + + conn.close() + except Exception: + pass + + # Check for timeout/exit failures via supervise exit status + if timer_active: + exit_file = FENRIS_SV_DIR / "supervise" / "exit" + if exit_file.exists(): + try: + exit_code = int(exit_file.read_text().strip()) + if exit_code != 0: + last_collect_ok = False + last_collect_reason = "exit code %d" % exit_code + except (ValueError, OSError): + pass + + return { + "boot_enabled": boot_enabled, + "timer_active": timer_active, + "last_collect_ok": last_collect_ok, + "last_collect_age_s": last_collect_age_s, + "last_collect_reason": last_collect_reason, + } + + +def _runit_journal_hint(lines: int = 5) -> Optional[str]: + """Get the last N log lines for fenris-collect from runit logging.""" + log_current = FENRIS_LOG_DIR / "current" + if not log_current.exists(): + return None + try: + # Use tail to get the last N lines + result = subprocess.run( + ["tail", "-n", str(lines), str(log_current)], + capture_output=True, text=True, timeout=5, + ) + if result.returncode != 0 or not result.stdout.strip(): + return None + return result.stdout.strip() + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return None + + +# --------------------------------------------------------------------------- +# Public API — unified interface +# --------------------------------------------------------------------------- + +def enable_timer(now: bool) -> None: + """Enable the collection timer/service.""" + init = get_init_system() + if init == InitSystem.SYSTEMD: + _systemd_enable(now) + else: + _runit_enable(now) + + +def disable_timer(now: bool) -> None: + """Disable the collection timer/service.""" + init = get_init_system() + if init == InitSystem.SYSTEMD: + _systemd_disable(now) + else: + _runit_disable(now) + + +def collect_now() -> None: + """Trigger on-demand collection (blocking).""" + init = get_init_system() + if init == InitSystem.SYSTEMD: + _systemd_collect() + else: + _runit_collect() + + +def query_service_state() -> Dict[str, Any]: + """Query the four separate service facts.""" + init = get_init_system() + if init == InitSystem.SYSTEMD: + return _systemd_query_state() + else: + return _runit_query_state() + + +def journal_hint(lines: int = 5, unit: str = "fenris-collect.service") -> Optional[str]: + """Get journal/log hints for diagnostics. + + The unit parameter is accepted for backward compatibility with callers + that pass 'fenris-collect.service'. For runit, the unit parameter is + ignored since the log directory is always /var/log/fenris-collect/. + """ + init = get_init_system() + if init == InitSystem.SYSTEMD: + return _systemd_journal_hint(lines) + else: + return _runit_journal_hint(lines) diff --git a/src/fenris/monitor.py b/src/fenris/monitor.py index 987f8c2..414cdc7 100644 --- a/src/fenris/monitor.py +++ b/src/fenris/monitor.py @@ -15,9 +15,7 @@ When run as a script, uses the fenris package from the installed wheel. import argparse import json import os -import subprocess import sys -import sqlite3 from datetime import datetime, timezone from pathlib import Path @@ -39,6 +37,11 @@ from fenris.monitoring_periods import ( close_period, get_open_period, ) +from fenris.init_system import ( + enable_timer, + disable_timer, + collect_now, +) DEFAULT_STORE_PATH = Path("/var/lib/fenris/observations.db") @@ -70,25 +73,8 @@ def cmd_enable(args: argparse.Namespace) -> None: else: print("Monitoring period already open (id=%d)" % open_period["id"]) - # Enable and start the timer - if args.now: - result = subprocess.run( - ["systemctl", "enable", "--now", "fenris-collect.timer"], - capture_output=True, - text=True, - ) - else: - result = subprocess.run( - ["systemctl", "enable", "fenris-collect.timer"], - capture_output=True, - text=True, - ) - - if result.returncode != 0: - print("Error enabling timer:", result.stderr, file=sys.stderr) - sys.exit(1) - - print("Timer enabled" + (" and started" if args.now else "")) + # Enable the timer (init-system aware) + enable_timer(args.now) finally: conn.close() @@ -117,25 +103,8 @@ def cmd_disable(args: argparse.Namespace) -> None: else: print("No open monitoring period (no-op)") - # Disable and stop the timer - if args.now: - result = subprocess.run( - ["systemctl", "disable", "--now", "fenris-collect.timer"], - capture_output=True, - text=True, - ) - else: - result = subprocess.run( - ["systemctl", "disable", "fenris-collect.timer"], - capture_output=True, - text=True, - ) - - if result.returncode != 0: - print("Error disabling timer:", result.stderr, file=sys.stderr) - sys.exit(1) - - print("Timer disabled" + (" and stopped" if args.now else "")) + # Disable the timer (init-system aware) + disable_timer(args.now) finally: conn.close() @@ -143,22 +112,12 @@ def cmd_disable(args: argparse.Namespace) -> None: def cmd_collect(args: argparse.Namespace) -> None: """Trigger on-demand collection. - Starts fenris-collect.service, blocks until exit, reports outcome. + Starts fenris-collect, blocks until exit, reports outcome. - Spec §8.7: fenris sample routes through fenris-monitor → systemctl start, - which blocks until the oneshot exits; outcome reported synchronously. + Spec §8.7: fenris sample routes through fenris-monitor → collect, + which blocks until the collection exits; outcome reported synchronously. """ - result = subprocess.run( - ["systemctl", "start", "fenris-collect.service"], - capture_output=True, - text=True, - ) - - if result.returncode == 0: - print("Collection completed successfully") - else: - print("Collection failed:", result.stderr, file=sys.stderr) - sys.exit(1) + collect_now() def cmd_baseline_set(args: argparse.Namespace) -> None: diff --git a/src/fenris/status.py b/src/fenris/status.py index 54b81b7..c7e9374 100644 --- a/src/fenris/status.py +++ b/src/fenris/status.py @@ -1,8 +1,8 @@ """Read-only CLI status command: the CLI twin of the TUI (spec §8.8, LC-9, CI-2). -Composes from the observation store (read-only) and allow-listed systemctl +Composes from the observation store (read-only) and allow-listed service properties: projection facts, four separate service facts (boot enablement, -runtime activity, last collect outcome, freshness), and a journalctl hint on +runtime activity, last collect outcome, freshness), and a journal/log hint on failure or staleness. Never auto-samples, never prompts. Freshness constants are defined once here and shared with the TUI (§8.9): @@ -14,7 +14,6 @@ Freshness constants are defined once here and shared with the TUI (§8.9): Criteria: LC-9, CI-2, CI-4, FL-4, FL-5, FL-7. """ import sqlite3 -import subprocess import sys from datetime import datetime, timedelta, timezone from pathlib import Path @@ -22,6 +21,10 @@ from typing import Any, Dict, List, Optional, Tuple from .projection import compute_projection, ConfidenceState, DISCLOSURES from .store import SCHEMA_VERSION +from .init_system import ( + query_service_state as _init_query_service_state, + journal_hint as _init_journal_hint, +) # --------------------------------------------------------------------------- @@ -130,97 +133,12 @@ class NewerSchema(Exception): # --------------------------------------------------------------------------- -# Service state queries (§8.8 — allow-listed systemctl properties) +# Service state queries (§8.8 — init-system agnostic) # --------------------------------------------------------------------------- -def _systemctl_show(unit: str, *properties: str) -> Dict[str, str]: - """Query systemctl show for specific properties. Returns empty dict on failure.""" - try: - result = subprocess.run( - ["systemctl", "show", unit, "--property=" + ",".join(properties)], - capture_output=True, text=True, timeout=5, - ) - if result.returncode != 0: - return {} - out = {} - for line in result.stdout.splitlines(): - if "=" in line: - key, _, value = line.partition("=") - out[key.strip()] = value.strip() - return out - except (subprocess.TimeoutExpired, FileNotFoundError, OSError): - return {} - - -def _journalctl_hint(unit: str, lines: int = 5) -> Optional[str]: - """Get the last N journal lines for a unit. Returns None on failure.""" - try: - result = subprocess.run( - ["journalctl", "-u", unit, "--no-pager", "-n", str(lines), "--output=short-iso"], - capture_output=True, text=True, timeout=5, - ) - if result.returncode != 0 or not result.stdout.strip(): - return None - return result.stdout.strip() - except (subprocess.TimeoutExpired, FileNotFoundError, OSError): - return None - - -def query_service_state() -> Dict[str, Any]: - """Query systemctl for the four separate service facts (§7.3, LC-9). - - Returns dict with keys: - boot_enabled: bool - timer_active: bool - last_collect_ok: Optional[bool] - last_collect_age_s: Optional[int] - last_collect_reason: Optional[str] - """ - timer_props = _systemctl_show( - "fenris-collect.timer", - "UnitFileState", "ActiveState", "LastTriggerUSec", - ) - service_props = _systemctl_show( - "fenris-collect.service", - "ActiveState", "ExecMainStatus", "ExecMainExitTimestamp", - ) - - boot_enabled_str = timer_props.get("UnitFileState", "") - boot_enabled = boot_enabled_str == "enabled" - - active_state = timer_props.get("ActiveState", "inactive") - timer_active = active_state == "active" - - last_collect_ok = None - last_collect_age_s = None - last_collect_reason = None - - last_trigger = timer_props.get("LastTriggerUSec", "") - if last_trigger and last_trigger != "n/a": - try: - trigger_dt = datetime.fromisoformat(last_trigger.replace("Z", "+00:00")) - now = datetime.now(timezone.utc) - last_collect_age_s = int((now - trigger_dt).total_seconds()) - except (ValueError, TypeError): - pass - - exec_status = service_props.get("ExecMainStatus", "") - if exec_status: - try: - exit_code = int(exec_status) - last_collect_ok = exit_code == 0 - if exit_code != 0: - last_collect_reason = "exit code %d" % exit_code - except (ValueError, TypeError): - pass - - return { - "boot_enabled": boot_enabled, - "timer_active": timer_active, - "last_collect_ok": last_collect_ok, - "last_collect_age_s": last_collect_age_s, - "last_collect_reason": last_collect_reason, - } +# Re-export for backward compatibility with tests that import directly +query_service_state = _init_query_service_state +_journalctl_hint = _init_journal_hint # --------------------------------------------------------------------------- @@ -601,7 +519,7 @@ def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] if store_fault or newer_schema: journal_hint = None if query_journal: - journal_hint = _journalctl_hint("fenris-collect.service") + journal_hint = _journalctl_hint() service["freshness"] = "unknown" service["freshness_age_s"] = None return _format_projection( @@ -663,7 +581,7 @@ def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] # --- Journal hint on failure or staleness (§8.8) --- journal_hint = None if query_journal and freshness in ("missed", "stale"): - journal_hint = _journalctl_hint("fenris-collect.service") + journal_hint = _journalctl_hint() # --- Compose output --- result = _format_projection( diff --git a/tests/test_init_system.py b/tests/test_init_system.py new file mode 100644 index 0000000..9a90651 --- /dev/null +++ b/tests/test_init_system.py @@ -0,0 +1,659 @@ +"""Tests for the init system abstraction layer (issue #84). + +Covers: + - Init system detection (systemd vs runit) + - systemd backend functions (enable, disable, collect, query state) + - runit backend functions (enable, disable, collect, query state) + - Public API dispatching to correct backend + - Edge cases (already enabled/disabled, missing files, timeouts) + +Spec: ADR 0008, §8.4, §8.5, §8.6, §8.7, §8.8 +""" +import os +import sqlite3 +import tempfile +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest.mock import patch, MagicMock, PropertyMock + +import pytest + +import sys +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from fenris.init_system import ( + InitSystem, + detect_init_system, + get_init_system, + reset_init_system_cache, + _systemd_enable, + _systemd_disable, + _systemd_collect, + _systemd_query_state, + _runit_enable, + _runit_disable, + _runit_collect, + _runit_query_state, + _runit_is_enabled, + _runit_is_running, + enable_timer, + disable_timer, + collect_now, + query_service_state, + journal_hint, + FENRIS_SV_DIR, + FENRIS_SERVICE_LINK, + COLLECT_TIMEOUT_S, +) +from fenris.store import init_store + + +@pytest.fixture(autouse=True) +def reset_cache(): + """Reset the init system cache before each test.""" + reset_init_system_cache() + yield + reset_init_system_cache() + + +# --------------------------------------------------------------------------- +# Init system detection +# --------------------------------------------------------------------------- + +class TestInitSystemDetection: + """Test init system detection logic.""" + + def test_detect_systemd_by_run_directory(self): + """Systemd detected via /run/systemd/system directory.""" + with patch("pathlib.Path.exists") as mock_exists: + mock_exists.return_value = True + reset_init_system_cache() + result = detect_init_system() + assert result == InitSystem.SYSTEMD + + def test_detect_systemd_by_pid1(self): + """Systemd detected via PID 1 name.""" + original_exists = Path.exists + original_read_text = Path.read_text + + def mock_exists(self_path): + if str(self_path) == "/run/systemd/system": + return False + return original_exists(self_path) + + def mock_read_text(self_path): + if str(self_path) == "/proc/1/comm": + return "systemd" + return original_read_text(self_path) + + with patch("pathlib.Path.exists", mock_exists), \ + patch("pathlib.Path.read_text", mock_read_text): + reset_init_system_cache() + result = detect_init_system() + assert result == InitSystem.SYSTEMD + + def test_detect_runit_by_pid1(self): + """Runit detected via PID 1 name.""" + original_exists = Path.exists + original_read_text = Path.read_text + + def mock_exists(self_path): + if str(self_path) == "/run/systemd/system": + return False + return original_exists(self_path) + + def mock_read_text(self_path): + if str(self_path) == "/proc/1/comm": + return "runsv" + return original_read_text(self_path) + + with patch("pathlib.Path.exists", mock_exists), \ + patch("pathlib.Path.read_text", mock_read_text): + reset_init_system_cache() + result = detect_init_system() + assert result == InitSystem.RUNIT + + def test_detect_runit_by_etc_sv(self): + """Runit detected via /etc/sv directory.""" + original_exists = Path.exists + original_read_text = Path.read_text + + def mock_exists(self_path): + if str(self_path) == "/run/systemd/system": + return False + if str(self_path) == "/etc/sv": + return True + return original_exists(self_path) + + def mock_read_text(self_path): + if str(self_path) == "/proc/1/comm": + raise OSError("no such file") + return original_read_text(self_path) + + with patch("pathlib.Path.exists", mock_exists), \ + patch("pathlib.Path.read_text", mock_read_text): + reset_init_system_cache() + result = detect_init_system() + assert result == InitSystem.RUNIT + + def test_default_to_systemd(self): + """Default to systemd when no detection matches.""" + original_exists = Path.exists + original_read_text = Path.read_text + original_is_dir = Path.is_dir + + def mock_exists(self_path): + if str(self_path) == "/run/systemd/system": + return False + return original_exists(self_path) + + def mock_is_dir(self_path): + if str(self_path) == "/etc/sv": + return False + return original_is_dir(self_path) + + def mock_read_text(self_path): + if str(self_path) == "/proc/1/comm": + raise OSError("no such file") + return original_read_text(self_path) + + with patch("pathlib.Path.exists", mock_exists), \ + patch("pathlib.Path.is_dir", mock_is_dir), \ + patch("pathlib.Path.read_text", mock_read_text): + reset_init_system_cache() + result = detect_init_system() + assert result == InitSystem.SYSTEMD + + +# --------------------------------------------------------------------------- +# systemd backend +# --------------------------------------------------------------------------- + +class TestSystemdEnable: + """Test systemd enable function.""" + + def test_enable_now(self): + """Enable with --now flag.""" + with patch("fenris.init_system.subprocess") as mock_sub: + mock_sub.run.return_value = MagicMock(returncode=0) + _systemd_enable(now=True) + mock_sub.run.assert_called_once_with( + ["systemctl", "enable", "--now", "fenris-collect.timer"], + capture_output=True, text=True, + ) + + def test_enable_without_now(self): + """Enable without --now flag.""" + with patch("fenris.init_system.subprocess") as mock_sub: + mock_sub.run.return_value = MagicMock(returncode=0) + _systemd_enable(now=False) + mock_sub.run.assert_called_once_with( + ["systemctl", "enable", "fenris-collect.timer"], + capture_output=True, text=True, + ) + + def test_enable_failure_exits(self): + """Enable failure exits with error.""" + with patch("fenris.init_system.subprocess") as mock_sub: + mock_sub.run.return_value = MagicMock( + returncode=1, stderr="Unit not found" + ) + with pytest.raises(SystemExit) as exc_info: + _systemd_enable(now=True) + assert exc_info.value.code == 1 + + +class TestSystemdDisable: + """Test systemd disable function.""" + + def test_disable_now(self): + """Disable with --now flag.""" + with patch("fenris.init_system.subprocess") as mock_sub: + mock_sub.run.return_value = MagicMock(returncode=0) + _systemd_disable(now=True) + mock_sub.run.assert_called_once_with( + ["systemctl", "disable", "--now", "fenris-collect.timer"], + capture_output=True, text=True, + ) + + def test_disable_without_now(self): + """Disable without --now flag.""" + with patch("fenris.init_system.subprocess") as mock_sub: + mock_sub.run.return_value = MagicMock(returncode=0) + _systemd_disable(now=False) + mock_sub.run.assert_called_once_with( + ["systemctl", "disable", "fenris-collect.timer"], + capture_output=True, text=True, + ) + + +class TestSystemdCollect: + """Test systemd collect function.""" + + def test_collect_success(self): + """Successful collection.""" + with patch("fenris.init_system.subprocess") as mock_sub: + mock_sub.run.return_value = MagicMock(returncode=0) + _systemd_collect() + mock_sub.run.assert_called_once_with( + ["systemctl", "start", "fenris-collect.service"], + capture_output=True, text=True, + ) + + def test_collect_failure_exits(self): + """Collection failure exits with error.""" + with patch("fenris.init_system.subprocess") as mock_sub: + mock_sub.run.return_value = MagicMock( + returncode=1, stderr="Unit not found" + ) + with pytest.raises(SystemExit) as exc_info: + _systemd_collect() + assert exc_info.value.code == 1 + + +class TestSystemdQueryState: + """Test systemd query state function.""" + + def test_query_state_enabled_active(self): + """Query state for enabled and active timer.""" + with patch("fenris.init_system._systemctl_show") as mock_show: + def mock_show_fn(unit, *props): + if unit == "fenris-collect.timer": + return { + "UnitFileState": "enabled", + "ActiveState": "active", + "LastTriggerUSec": "2026-09-01T12:00:00Z", + } + elif unit == "fenris-collect.service": + return { + "ActiveState": "inactive", + "ExecMainStatus": "0", + "ExecMainExitTimestamp": "2026-09-01T12:00:30Z", + } + return {} + mock_show.side_effect = mock_show_fn + + result = _systemd_query_state() + + assert result["boot_enabled"] is True + assert result["timer_active"] is True + assert result["last_collect_ok"] is True + assert result["last_collect_age_s"] is not None + + def test_query_state_disabled_inactive(self): + """Query state for disabled and inactive timer.""" + with patch("fenris.init_system._systemctl_show") as mock_show: + mock_show.return_value = {} + result = _systemd_query_state() + assert result["boot_enabled"] is False + assert result["timer_active"] is False + assert result["last_collect_ok"] is None + + +# --------------------------------------------------------------------------- +# runit backend +# --------------------------------------------------------------------------- + +class TestRunitEnable: + """Test runit enable function.""" + + def test_enable_creates_symlink(self, tmp_path): + """Enable creates symlink to service directory.""" + sv_dir = tmp_path / "sv" / "fenris-collect" + sv_dir.mkdir(parents=True) + service_link = tmp_path / "service" / "fenris-collect" + service_link.parent.mkdir(parents=True) + + with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \ + patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link): + _runit_enable(_now=True) + assert service_link.exists() + assert service_link.is_symlink() + assert service_link.resolve() == sv_dir + + def test_enable_removes_down_file(self, tmp_path): + """Enable removes the 'down' file if present.""" + sv_dir = tmp_path / "sv" / "fenris-collect" + sv_dir.mkdir(parents=True) + (sv_dir / "down").touch() + service_link = tmp_path / "service" / "fenris-collect" + service_link.parent.mkdir(parents=True) + + with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \ + patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link): + _runit_enable(_now=True) + assert not (sv_dir / "down").exists() + + def test_enable_idempotent(self, tmp_path): + """Enable is idempotent when already enabled.""" + sv_dir = tmp_path / "sv" / "fenris-collect" + sv_dir.mkdir(parents=True) + service_link = tmp_path / "service" / "fenris-collect" + service_link.parent.mkdir(parents=True) + service_link.symlink_to(sv_dir) + + with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \ + patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link): + _runit_enable(_now=True) + assert service_link.exists() + + +class TestRunitDisable: + """Test runit disable function.""" + + def test_disable_removes_symlink(self, tmp_path): + """Disable removes the service symlink.""" + sv_dir = tmp_path / "sv" / "fenris-collect" + sv_dir.mkdir(parents=True) + service_link = tmp_path / "service" / "fenris-collect" + service_link.parent.mkdir(parents=True) + service_link.symlink_to(sv_dir) + + with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \ + patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link): + _runit_disable(_now=True) + assert not service_link.exists() + assert (sv_dir / "down").exists() + + def test_disable_idempotent(self, tmp_path): + """Disable is idempotent when already disabled.""" + sv_dir = tmp_path / "sv" / "fenris-collect" + sv_dir.mkdir(parents=True) + service_link = tmp_path / "service" / "fenris-collect" + service_link.parent.mkdir(parents=True) + + with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \ + patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link): + _runit_disable(_now=True) + assert not service_link.exists() + + +class TestRunitCollect: + """Test runit collect function.""" + + def test_collect_uses_flock(self): + """Collect uses flock for serialization.""" + with patch("fenris.init_system.subprocess") as mock_sub, \ + patch("fenris.init_system.Path") as mock_path: + mock_path.return_value.exists.return_value = True + mock_sub.run.return_value = MagicMock(returncode=0) + _runit_collect() + # Verify flock was used + call_args = mock_sub.run.call_args[0][0] + assert "flock" in call_args + + def test_collect_timeout_exits(self): + """Collection timeout exits with error.""" + with patch("fenris.init_system.subprocess") as mock_sub, \ + patch("fenris.init_system.Path") as mock_path: + mock_path.return_value.exists.return_value = True + mock_sub.run.return_value = MagicMock(returncode=124) + with pytest.raises(SystemExit) as exc_info: + _runit_collect() + assert exc_info.value.code == 1 + + +class TestRunitQueryState: + """Test runit query state function.""" + + def test_query_state_enabled_running(self, tmp_path): + """Query state for enabled and running service.""" + sv_dir = tmp_path / "sv" / "fenris-collect" + sv_dir.mkdir(parents=True) + supervise_dir = sv_dir / "supervise" + supervise_dir.mkdir(parents=True) + (supervise_dir / "pid").write_text("12345") + service_link = tmp_path / "service" / "fenris-collect" + service_link.parent.mkdir(parents=True) + service_link.symlink_to(sv_dir) + + with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \ + patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link), \ + patch("os.kill") as mock_kill: + mock_kill.return_value = True # Process exists + result = _runit_query_state() + assert result["boot_enabled"] is True + assert result["timer_active"] is True + + def test_query_state_disabled_not_running(self, tmp_path): + """Query state for disabled and not running service.""" + sv_dir = tmp_path / "sv" / "fenris-collect" + sv_dir.mkdir(parents=True) + service_link = tmp_path / "service" / "fenris-collect" + service_link.parent.mkdir(parents=True) + + with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \ + patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link): + result = _runit_query_state() + assert result["boot_enabled"] is False + assert result["timer_active"] is False + + +class TestRunitIsEnabled: + """Test runit is_enabled check.""" + + def test_is_enabled_true(self, tmp_path): + """Service is enabled when symlink exists.""" + sv_dir = tmp_path / "sv" / "fenris-collect" + sv_dir.mkdir(parents=True) + service_link = tmp_path / "service" / "fenris-collect" + service_link.parent.mkdir(parents=True) + service_link.symlink_to(sv_dir) + + with patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link): + assert _runit_is_enabled() is True + + def test_is_enabled_false(self, tmp_path): + """Service is disabled when symlink does not exist.""" + service_link = tmp_path / "service" / "fenris-collect" + + with patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link): + assert _runit_is_enabled() is False + + +class TestRunitIsRunning: + """Test runit is_running check.""" + + def test_is_running_true(self, tmp_path): + """Service is running when PID file exists and process is alive.""" + sv_dir = tmp_path / "sv" / "fenris-collect" + supervise_dir = sv_dir / "supervise" + supervise_dir.mkdir(parents=True) + (supervise_dir / "pid").write_text("12345") + + with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \ + patch("os.kill") as mock_kill: + mock_kill.return_value = True + assert _runit_is_running() is True + + def test_is_running_false_no_pid(self, tmp_path): + """Service is not running when PID file does not exist.""" + sv_dir = tmp_path / "sv" / "fenris-collect" + sv_dir.mkdir(parents=True) + + with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir): + assert _runit_is_running() is False + + def test_is_running_false_dead_process(self, tmp_path): + """Service is not running when process is dead.""" + sv_dir = tmp_path / "sv" / "fenris-collect" + supervise_dir = sv_dir / "supervise" + supervise_dir.mkdir(parents=True) + (supervise_dir / "pid").write_text("12345") + + with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \ + patch("os.kill", side_effect=OSError("No such process")): + assert _runit_is_running() is False + + +# --------------------------------------------------------------------------- +# Public API dispatching +# --------------------------------------------------------------------------- + +class TestPublicAPI: + """Test public API dispatches to correct backend.""" + + def test_enable_dispatches_to_systemd(self): + """enable_timer dispatches to systemd on systemd system.""" + with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \ + patch("fenris.init_system._systemd_enable") as mock_enable: + enable_timer(now=True) + mock_enable.assert_called_once_with(True) + + def test_enable_dispatches_to_runit(self): + """enable_timer dispatches to runit on runit system.""" + with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \ + patch("fenris.init_system._runit_enable") as mock_enable: + enable_timer(now=True) + mock_enable.assert_called_once_with(True) + + def test_disable_dispatches_to_systemd(self): + """disable_timer dispatches to systemd on systemd system.""" + with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \ + patch("fenris.init_system._systemd_disable") as mock_disable: + disable_timer(now=False) + mock_disable.assert_called_once_with(False) + + def test_disable_dispatches_to_runit(self): + """disable_timer dispatches to runit on runit system.""" + with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \ + patch("fenris.init_system._runit_disable") as mock_disable: + disable_timer(now=False) + mock_disable.assert_called_once_with(False) + + def test_collect_dispatches_to_systemd(self): + """collect_now dispatches to systemd on systemd system.""" + with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \ + patch("fenris.init_system._systemd_collect") as mock_collect: + collect_now() + mock_collect.assert_called_once() + + def test_collect_dispatches_to_runit(self): + """collect_now dispatches to runit on runit system.""" + with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \ + patch("fenris.init_system._runit_collect") as mock_collect: + collect_now() + mock_collect.assert_called_once() + + def test_query_state_dispatches_to_systemd(self): + """query_service_state dispatches to systemd on systemd system.""" + with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \ + patch("fenris.init_system._systemd_query_state") as mock_query: + query_service_state() + mock_query.assert_called_once() + + def test_query_state_dispatches_to_runit(self): + """query_service_state dispatches to runit on runit system.""" + with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \ + patch("fenris.init_system._runit_query_state") as mock_query: + query_service_state() + mock_query.assert_called_once() + + def test_journal_hint_dispatches_to_systemd(self): + """journal_hint dispatches to systemd on systemd system.""" + with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \ + patch("fenris.init_system._systemd_journal_hint") as mock_hint: + journal_hint(lines=3, unit="fenris-collect.service") + mock_hint.assert_called_once_with(3) + + def test_journal_hint_dispatches_to_runit(self): + """journal_hint dispatches to runit on runit system.""" + with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \ + patch("fenris.init_system._runit_journal_hint") as mock_hint: + journal_hint(lines=3, unit="fenris-collect.service") + mock_hint.assert_called_once_with(3) + + +# --------------------------------------------------------------------------- +# Constants and configuration +# --------------------------------------------------------------------------- + +class TestConstants: + """Test constants match spec requirements.""" + + def test_collect_timeout(self): + """Collection timeout is 90 seconds (bounded execution).""" + assert COLLECT_TIMEOUT_S == 90 + + def test_init_system_enum(self): + """InitSystem enum has systemd and runit variants.""" + assert InitSystem.SYSTEMD.value == "systemd" + assert InitSystem.RUNIT.value == "runit" + + +# --------------------------------------------------------------------------- +# Integration with monitor.py +# --------------------------------------------------------------------------- + +class TestMonitorIntegration: + """Test that monitor.py uses the abstraction layer correctly.""" + + def test_cmd_enable_uses_init_system(self, tmp_path): + """cmd_enable uses init_system.enable_timer.""" + from fenris.monitor import cmd_enable + from argparse import Namespace + + store_path = tmp_path / "observations.db" + init_store(store_path) + args = Namespace(now=True, store_path=store_path) + + with patch("fenris.monitor.enable_timer") as mock_enable: + cmd_enable(args) + mock_enable.assert_called_once_with(True) + + def test_cmd_disable_uses_init_system(self, tmp_path): + """cmd_disable uses init_system.disable_timer.""" + from fenris.monitor import cmd_disable + from argparse import Namespace + + store_path = tmp_path / "observations.db" + init_store(store_path) + args = Namespace(now=True, store_path=store_path) + + with patch("fenris.monitor.disable_timer") as mock_disable: + cmd_disable(args) + mock_disable.assert_called_once_with(True) + + def test_cmd_collect_uses_init_system(self): + """cmd_collect uses init_system.collect_now.""" + from fenris.monitor import cmd_collect + from argparse import Namespace + + args = Namespace() + + with patch("fenris.monitor.collect_now") as mock_collect: + cmd_collect(args) + mock_collect.assert_called_once() + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + +class TestEdgeCases: + """Test edge cases and error handling.""" + + def test_systemd_enable_failure_produces_stderr(self): + """Systemd enable failure produces error message on stderr.""" + with patch("fenris.init_system.subprocess") as mock_sub: + mock_sub.run.return_value = MagicMock( + returncode=1, stderr="Permission denied" + ) + with pytest.raises(SystemExit): + _systemd_enable(now=True) + + def test_runit_collect_missing_script(self): + """Runit collect exits when fenris-collect script not found.""" + with patch("fenris.init_system.Path") as mock_path: + mock_path.return_value.exists.return_value = False + with pytest.raises(SystemExit) as exc_info: + _runit_collect() + assert exc_info.value.code == 1 + + def test_systemd_query_state_timeout(self): + """Systemd query state handles subprocess timeout.""" + with patch("fenris.init_system._systemctl_show") as mock_show: + mock_show.return_value = {} + result = _systemd_query_state() + assert result["boot_enabled"] is False + assert result["timer_active"] is False diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 3571a7c..6ee6543 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -56,8 +56,7 @@ class TestEnableIdempotentMatrix: """A fresh package install has a store directory but no database yet.""" args = MagicMock(now=False, store_path=store_path) - with patch("fenris.monitor.subprocess") as mock_sub: - mock_sub.run.return_value = MagicMock(returncode=0) + with patch("fenris.monitor.enable_timer") as mock_enable: cmd_enable(args) conn = init_store(store_path) @@ -74,8 +73,7 @@ class TestEnableIdempotentMatrix: args = MagicMock(now=False, store_path=store_path) - with patch("fenris.monitor.subprocess") as mock_sub: - mock_sub.run.return_value = MagicMock(returncode=0) + with patch("fenris.monitor.enable_timer") as mock_enable: cmd_enable(args) # Period should be open @@ -100,8 +98,7 @@ class TestEnableIdempotentMatrix: args = MagicMock(now=True, store_path=store_path) - with patch("fenris.monitor.subprocess") as mock_sub: - mock_sub.run.return_value = MagicMock(returncode=0) + with patch("fenris.monitor.enable_timer") as mock_enable: cmd_enable(args) # Should still have exactly one open period @@ -125,8 +122,7 @@ class TestEnableIdempotentMatrix: args = MagicMock(now=True, store_path=store_path) - with patch("fenris.monitor.subprocess") as mock_sub: - mock_sub.run.return_value = MagicMock(returncode=0) + with patch("fenris.monitor.enable_timer") as mock_enable: cmd_enable(args) # Should have a new open period @@ -155,8 +151,7 @@ class TestDisableIdempotentMatrix: args = MagicMock(now=True, store_path=store_path) - with patch("fenris.monitor.subprocess") as mock_sub: - mock_sub.run.return_value = MagicMock(returncode=0) + with patch("fenris.monitor.disable_timer") as mock_disable: cmd_disable(args) # Period should be closed with user_disabled @@ -174,8 +169,7 @@ class TestDisableIdempotentMatrix: args = MagicMock(now=True, store_path=store_path) - with patch("fenris.monitor.subprocess") as mock_sub: - mock_sub.run.return_value = MagicMock(returncode=0) + with patch("fenris.monitor.disable_timer") as mock_disable: cmd_disable(args) # No periods should exist @@ -207,28 +201,19 @@ class TestDisableIdempotentMatrix: class TestCollectTrigger: """§8.7: On-demand collection via helper path.""" - def test_collect_triggers_systemctl_start(self): - """Collect starts fenris-collect.service synchronously.""" + def test_collect_triggers_init_system(self): + """Collect triggers init_system.collect_now.""" args = MagicMock() - with patch("fenris.monitor.subprocess") as mock_sub: - mock_sub.run.return_value = MagicMock(returncode=0) + with patch("fenris.monitor.collect_now") as mock_collect: cmd_collect(args) - - mock_sub.run.assert_called_once_with( - ["systemctl", "start", "fenris-collect.service"], - capture_output=True, - text=True, - ) + mock_collect.assert_called_once() def test_collect_failure_exits_nonzero(self): """Collect failure exits with nonzero status.""" args = MagicMock() - with patch("fenris.monitor.subprocess") as mock_sub: - mock_sub.run.return_value = MagicMock( - returncode=1, stderr="Unit not found" - ) + with patch("fenris.monitor.collect_now", side_effect=SystemExit(1)): with pytest.raises(SystemExit) as exc_info: cmd_collect(args) assert exc_info.value.code == 1 diff --git a/units/runit/fenris-collect/log/run b/units/runit/fenris-collect/log/run new file mode 100755 index 0000000..6f4e908 --- /dev/null +++ b/units/runit/fenris-collect/log/run @@ -0,0 +1,9 @@ +#!/bin/sh +# fenris-collect runit log script — service output to /var/log/fenris-collect/ +# +# Runit automatically pipes the service's stdout/stderr to this logger's stdin. +# The logger writes to runit's log directory for diagnostics. +# +# Spec: §8.8 (actionable native diagnostics) +exec chpst -u fenris:fenris \ + svlogd -tt /var/log/fenris-collect/ diff --git a/units/runit/fenris-collect/run b/units/runit/fenris-collect/run new file mode 100755 index 0000000..a506ddf --- /dev/null +++ b/units/runit/fenris-collect/run @@ -0,0 +1,40 @@ +#!/bin/sh +# fenris-collect runit run script — periodic NVMe collection scheduler. +# +# Runit service layout: +# /etc/sv/fenris-collect/run — this script (scheduler) +# /etc/sv/fenris-collect/log/run — logger to /var/log/fenris-collect/ +# /var/service/fenris-collect — symlink to enable +# /etc/sv/fenris-collect/down — marker for dormant install +# +# Guarantees (must match systemd timer semantics): +# - Initial 2-minute boot delay (sleep 120 before first collect) +# - Completion-relative 5-minute cadence (sleep 300 after collect) +# - Bounded execution (timeout 90s on each collect) +# - No catch-up (fixed sleep interval, no Persistent=) +# - Serialized runs (runsv does not restart until exit) +# - Serialized on-demand (flock serializes fenris-collect execution) +# +# Spec: §8.4, §8.5, §8.6, ADR 0008 +set -eu + +COLLECT_TIMEOUT=90 +BOOT_DELAY=120 +CADENCE=300 +LOCK_FILE=/var/lib/fenris/fenris-collect.lock + +# Ensure lock directory exists +mkdir -p "$(dirname "$LOCK_FILE")" + +# Initial boot delay: sleep before first collection +sleep "$BOOT_DELAY" + +# Collection loop: collect, then sleep for cadence +while true; do + flock --nonblock "$LOCK_FILE" \ + timeout "$COLLECT_TIMEOUT" nice ionice -c3 \ + /usr/libexec/fenris/fenris-collect \ + 2>&1 || true + + sleep "$CADENCE" +done