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>
This commit is contained in:
xavierk
2026-09-15 00:15:24 +05:30
co-authored by CommandCodeBot
parent 37a0ed7030
commit e27052d09a
15 changed files with 1390 additions and 227 deletions
+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(