#!/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()