Compare commits

...
2 Commits
Author SHA1 Message Date
xavierkandCommandCodeBot a279c56be5 chore: ignore MagicMock test artifacts
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-15 01:04:03 +05:30
xavierkandCommandCodeBot e27052d09a Implement runit support for Fenris monitoring (issue #84)
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 <noreply@commandcode.ai>
2026-09-15 00:15:24 +05:30
16 changed files with 1391 additions and 227 deletions
+1
View File
@@ -18,3 +18,4 @@ json
src/fenris.egg-info/
.pytest_cache/
.venv/
MagicMock*
+12
View File
@@ -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
+45 -23
View File
@@ -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)
;;
+14 -1
View File
@@ -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
+15 -2
View File
@@ -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
+46 -24
View File
@@ -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
+14 -1
View File
@@ -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
+15 -2
View File
@@ -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
+6
View File
@@ -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"
+479
View File
@@ -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)
+13 -54
View File
@@ -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:
+12 -94
View File
@@ -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(
+659
View File
@@ -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
+11 -26
View File
@@ -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
+9
View File
@@ -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/
+40
View File
@@ -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