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>
243 lines
7.6 KiB
Python
243 lines
7.6 KiB
Python
#!/usr/bin/env python3
|
|
"""fenris-monitor: privileged helper for toggle, collect, and baseline operations.
|
|
|
|
This binary is the ONLY sanctioned control path for:
|
|
- enable/disable (toggle) with monitoring-period bookkeeping
|
|
- on-demand collection trigger
|
|
- baseline set/clear persistence
|
|
|
|
Polkit authorizes this binary under com.bongbetic.fenris.monitor (auth_admin).
|
|
|
|
Spec: §8.4, §8.5, §8.6, §8.7
|
|
|
|
When run as a script, uses the fenris package from the installed wheel.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
# Package dependencies are vendored independently of the host Python minor
|
|
# version. Keep the venv fallback for the legacy development install.
|
|
VENV_DIR = Path("/opt/fenris")
|
|
if VENV_DIR.exists():
|
|
vendor_dir = VENV_DIR / "vendor"
|
|
if vendor_dir.is_dir():
|
|
sys.path.insert(0, str(vendor_dir))
|
|
else:
|
|
site_packages = next((VENV_DIR / "lib").glob("python*/site-packages"), None)
|
|
if site_packages:
|
|
sys.path.insert(0, str(site_packages))
|
|
|
|
from fenris.store import init_store, get_store_path
|
|
from fenris.monitoring_periods import (
|
|
ensure_period_open,
|
|
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")
|
|
|
|
|
|
def is_root() -> bool:
|
|
"""Check if running as root."""
|
|
return os.geteuid() == 0
|
|
|
|
|
|
def cmd_enable(args: argparse.Namespace) -> None:
|
|
"""Enable monitoring: enable timer + open monitoring period.
|
|
|
|
Idempotent matrix (§8.6):
|
|
- First-ever enable: opens a period at the enable moment
|
|
- Resume with open period: no-op (gap stays inside as unknown)
|
|
- Resume with no open period: opens a new row
|
|
"""
|
|
store_path = getattr(args, 'store_path', DEFAULT_STORE_PATH)
|
|
conn = init_store(store_path)
|
|
now = datetime.now(timezone.utc)
|
|
|
|
try:
|
|
# Open monitoring period if none exists (§8.6)
|
|
open_period = get_open_period(conn)
|
|
if open_period is None:
|
|
ensure_period_open(conn, now)
|
|
print("Monitoring period opened at", now.isoformat())
|
|
else:
|
|
print("Monitoring period already open (id=%d)" % open_period["id"])
|
|
|
|
# Enable the timer (init-system aware)
|
|
enable_timer(args.now)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def cmd_disable(args: argparse.Namespace) -> None:
|
|
"""Disable monitoring: disable timer + close monitoring period.
|
|
|
|
Idempotent matrix (§8.6):
|
|
- Pause with open period: closes it user_disabled
|
|
- Pause otherwise: no-op
|
|
"""
|
|
store_path = getattr(args, 'store_path', DEFAULT_STORE_PATH)
|
|
if not store_path.exists():
|
|
print("Error: Observation store not found at", store_path, file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
conn = init_store(store_path)
|
|
now = datetime.now(timezone.utc)
|
|
|
|
try:
|
|
# Close monitoring period if open (§8.6)
|
|
open_period = get_open_period(conn)
|
|
if open_period is not None:
|
|
close_period(conn, now, "user_disabled")
|
|
print("Monitoring period closed (id=%d)" % open_period["id"])
|
|
else:
|
|
print("No open monitoring period (no-op)")
|
|
|
|
# Disable the timer (init-system aware)
|
|
disable_timer(args.now)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def cmd_collect(args: argparse.Namespace) -> None:
|
|
"""Trigger on-demand collection.
|
|
|
|
Starts fenris-collect, blocks until exit, reports outcome.
|
|
|
|
Spec §8.7: fenris sample routes through fenris-monitor → collect,
|
|
which blocks until the collection exits; outcome reported synchronously.
|
|
"""
|
|
collect_now()
|
|
|
|
|
|
def cmd_baseline_set(args: argparse.Namespace) -> None:
|
|
"""Persist a baseline row after CLI-side validation.
|
|
|
|
Spec §8.4: fenris-monitor persists CLI-validated baseline rows.
|
|
Spec PR-14: baseline set persists through polkit-guarded helper.
|
|
"""
|
|
store_path = getattr(args, 'store_path', DEFAULT_STORE_PATH)
|
|
if not store_path.exists():
|
|
print("Error: Observation store not found at", store_path, file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
conn = init_store(store_path)
|
|
now = datetime.now(timezone.utc)
|
|
|
|
try:
|
|
# Parse and validate baseline data
|
|
data = json.loads(args.baseline_json)
|
|
|
|
required_fields = [
|
|
"tbw_terabytes",
|
|
"source_url",
|
|
"document_revision",
|
|
"entry_date",
|
|
"model_string",
|
|
"nominal_capacity_bytes",
|
|
]
|
|
for field in required_fields:
|
|
if field not in data:
|
|
print(f"Error: Missing required field: {field}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# One active row replaced on edit (§6.2)
|
|
conn.execute("DELETE FROM endurance_baseline")
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO endurance_baseline (
|
|
tbw_terabytes, source_url, document_revision,
|
|
entry_date, model_string, nominal_capacity_bytes,
|
|
validated_by, verified, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
data["tbw_terabytes"],
|
|
data["source_url"],
|
|
data["document_revision"],
|
|
data["entry_date"],
|
|
data["model_string"],
|
|
data["nominal_capacity_bytes"],
|
|
data.get("validated_by", "user"),
|
|
data.get("verified", False),
|
|
now.isoformat(),
|
|
now.isoformat(),
|
|
),
|
|
)
|
|
conn.commit()
|
|
print("Baseline persisted")
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def cmd_baseline_clear(args: argparse.Namespace) -> None:
|
|
"""Clear the endurance baseline.
|
|
|
|
Spec PR-14: baseline clear persists through polkit-guarded helper.
|
|
"""
|
|
store_path = getattr(args, 'store_path', DEFAULT_STORE_PATH)
|
|
if not store_path.exists():
|
|
print("Error: Observation store not found at", store_path, file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
conn = init_store(store_path)
|
|
try:
|
|
conn.execute("DELETE FROM endurance_baseline")
|
|
conn.commit()
|
|
print("Baseline cleared")
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
prog="fenris-monitor",
|
|
description="Fenris privileged helper for toggle, collect, and baseline operations.",
|
|
)
|
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
|
|
# enable/disable
|
|
enable_parser = subparsers.add_parser("enable", help="Enable monitoring")
|
|
enable_parser.add_argument(
|
|
"--now", action="store_true", help="Also start the timer immediately"
|
|
)
|
|
enable_parser.set_defaults(func=cmd_enable)
|
|
|
|
disable_parser = subparsers.add_parser("disable", help="Disable monitoring")
|
|
disable_parser.add_argument(
|
|
"--now", action="store_true", help="Also stop the timer immediately"
|
|
)
|
|
disable_parser.set_defaults(func=cmd_disable)
|
|
|
|
# collect
|
|
collect_parser = subparsers.add_parser("collect", help="Trigger on-demand collection")
|
|
collect_parser.set_defaults(func=cmd_collect)
|
|
|
|
# baseline
|
|
baseline_parser = subparsers.add_parser("baseline", help="Baseline operations")
|
|
baseline_sub = baseline_parser.add_subparsers(dest="baseline_action", required=True)
|
|
|
|
baseline_set = baseline_sub.add_parser("set", help="Persist baseline")
|
|
baseline_set.add_argument("baseline_json", help="Baseline JSON data")
|
|
baseline_set.set_defaults(func=cmd_baseline_set)
|
|
|
|
baseline_clear = baseline_sub.add_parser("clear", help="Clear baseline")
|
|
baseline_clear.set_defaults(func=cmd_baseline_clear)
|
|
|
|
args = parser.parse_args()
|
|
args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|