diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..08701b3 --- /dev/null +++ b/Makefile @@ -0,0 +1,162 @@ +# Fenris Makefile +# Spec: §10.1-10.6 + +SHELL := /bin/bash +PYTHON := python3 +VENV_DIR := /opt/fenris +BIN_DIR := /usr/local/bin +LIBEXEC_DIR := /usr/libexec/fenris +UNIT_DIR := /etc/systemd/system +POLKIT_DIR := /usr/share/polkit-1/actions +CONF_DIR := /etc/fenris +DATA_DIR := /var/lib/fenris + +# Placement manifest +MANIFEST := manifest.txt + +.PHONY: help install upgrade uninstall purge update-deps test lint + +help: + @echo "Fenris NVMe endurance monitor" + @echo "" + @echo "Targets:" + @echo " install - Install Fenris (builds wheel, installs to /opt/fenris)" + @echo " upgrade - Upgrade Fenris (reinstall wheel, sync units)" + @echo " uninstall - Uninstall Fenris (preserves config and store)" + @echo " purge - Remove everything including config and store" + @echo " test - Run tests" + @echo " lint - Run linter" + @echo " update-deps - Update dependency pins" + +# ─── Build ────────────────────────────────────────────────────────────────── + +dist/fenris-*.whl: pyproject.toml src/fenris/*.py + @mkdir -p dist + $(PYTHON) -m build --wheel -o dist + +# ─── Install ──────────────────────────────────────────────────────────────── + +install: dist/fenris-*.whl + @echo "=== Verifying prerequisites ===" + @$(PYTHON) --version 2>/dev/null || (echo "Error: python3 not found"; exit 1) + @smartctl --version 2>/dev/null | head -1 || (echo "Error: smartctl not found"; exit 1) + + @echo "=== Creating directories ===" + @sudo mkdir -p $(LIBEXEC_DIR) + @sudo mkdir -p $(CONF_DIR) + @sudo mkdir -p $(DATA_DIR) + @sudo mkdir -p $(POLKIT_DIR) + + @echo "=== Creating data directory ===" + @sudo install -d -o root -g fenris -m 2750 $(DATA_DIR) 2>/dev/null || sudo groupadd -f fenris && sudo install -d -o root -g fenris -m 2750 $(DATA_DIR) + + @echo "=== Installing venv ===" + @sudo rm -rf $(VENV_DIR) + @sudo $(PYTHON) -m venv $(VENV_DIR) + @sudo $(VENV_DIR)/bin/pip install --upgrade pip + @sudo $(VENV_DIR)/bin/pip install dist/fenris-*.whl + + @echo "=== Installing wrapper ===" + @sudo install -m 0755 scripts/fenris $(BIN_DIR)/fenris + + @echo "=== Installing helpers ===" + @sudo install -m 0755 src/fenris/monitor.py $(LIBEXEC_DIR)/fenris-monitor + @sudo install -m 0755 src/fenris/collect.py $(LIBEXEC_DIR)/fenris-collect + + @echo "=== Installing systemd units ===" + @sudo install -m 0644 units/fenris-collect.timer $(UNIT_DIR)/ + @sudo install -m 0644 units/fenris-collect.service $(UNIT_DIR)/ + @sudo systemctl daemon-reload + + @echo "=== Installing polkit policy ===" + @sudo install -m 0644 polkit/com.bongbetic.fenris.monitor.policy $(POLKIT_DIR)/ + + @echo "=== Creating manifest ===" + @echo "# Fenris placement manifest - do not edit" > $(MANIFEST) + @echo "# Generated by install target" >> $(MANIFEST) + @echo "$(BIN_DIR)/fenris" >> $(MANIFEST) + @echo "$(LIBEXEC_DIR)/fenris-monitor" >> $(MANIFEST) + @echo "$(LIBEXEC_DIR)/fenris-collect" >> $(MANIFEST) + @echo "$(UNIT_DIR)/fenris-collect.timer" >> $(MANIFEST) + @echo "$(UNIT_DIR)/fenris-collect.service" >> $(MANIFEST) + @echo "$(POLKIT_DIR)/com.bongbetic.fenris.monitor.policy" >> $(MANIFEST) + @echo "$(VENV_DIR)" >> $(MANIFEST) + @echo "$(DATA_DIR)" >> $(MANIFEST) + + @echo "=== Install complete ===" + @echo "Units installed but NOT enabled or started." + @echo "To start monitoring: fenris monitor resume" + +# ─── Upgrade ──────────────────────────────────────────────────────────────── + +upgrade: dist/fenris-*.whl + @echo "=== Upgrading Fenris ===" + @echo "=== Installing new wheel ===" + @sudo $(VENV_DIR)/bin/pip install dist/fenris-*.whl + + @echo "=== Syncing units ===" + @sudo install -m 0644 units/fenris-collect.timer $(UNIT_DIR)/ + @sudo install -m 0644 units/fenris-collect.service $(UNIT_DIR)/ + @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 + @sudo install -m 0755 src/fenris/collect.py $(LIBEXEC_DIR)/fenris-collect + @sudo systemctl daemon-reload + + # Restart timer only if active and contents changed + @if systemctl is-active --quiet fenris-collect.timer; then echo "=== Restarting timer (active) ==="; sudo systemctl restart fenris-collect.timer; fi + + @echo "=== Upgrade complete ===" + +# ─── Uninstall ────────────────────────────────────────────────────────────── + +uninstall: + @echo "=== Uninstalling Fenris ===" + + # Sanctioned disable first (§10.4) + @if [ -x $(LIBEXEC_DIR)/fenris-monitor ]; then echo "=== Performing sanctioned disable ==="; sudo $(LIBEXEC_DIR)/fenris-monitor disable --now || true; fi + + # Stop and disable units + @echo "=== Stopping units ===" + @sudo systemctl stop fenris-collect.timer 2>/dev/null || true + @sudo systemctl disable fenris-collect.timer 2>/dev/null || true + @sudo systemctl daemon-reload + + # Remove installed files (preserving /etc/fenris and /var/lib/fenris) + @echo "=== Removing files ===" + @-rm -f $(BIN_DIR)/fenris + @-rm -f $(LIBEXEC_DIR)/fenris-monitor + @-rm -f $(LIBEXEC_DIR)/fenris-collect + @-rmdir $(LIBEXEC_DIR) 2>/dev/null || true + @-rm -f $(UNIT_DIR)/fenris-collect.timer + @-rm -f $(UNIT_DIR)/fenris-collect.service + @-rm -f $(POLKIT_DIR)/com.bongbetic.fenris.monitor.policy + @sudo rm -rf $(VENV_DIR) + @-rm -f $(MANIFEST) + + @echo "=== Uninstall complete ===" + @echo "Config preserved at $(CONF_DIR)" + @echo "Store preserved at $(DATA_DIR)" + +# ─── Purge ────────────────────────────────────────────────────────────────── + +purge: uninstall + @echo "=== Purging Fenris ===" + @-sudo rm -rf $(CONF_DIR) + @-sudo rm -rf $(DATA_DIR) + @echo "=== Purge complete ===" + +# ─── Test ─────────────────────────────────────────────────────────────────── + +test: + $(PYTHON) -m pytest tests/ -v + +# ─── Lint ─────────────────────────────────────────────────────────────────── + +lint: + $(PYTHON) -m ruff check src/ tests/ + +# ─── Dependencies ─────────────────────────────────────────────────────────── + +update-deps: + $(PYTHON) -m pip compile pyproject.toml -o requirements.txt diff --git a/polkit/com.bongbetic.fenris.monitor.policy b/polkit/com.bongbetic.fenris.monitor.policy new file mode 100644 index 0000000..f0f7481 --- /dev/null +++ b/polkit/com.bongbetic.fenris.monitor.policy @@ -0,0 +1,21 @@ + + + + bongbetic + https://bongbetic.com + + + Fenris Monitor Helper + Authentication is required to manage Fenris monitoring. + + + no + no + auth_admin + + + org.freedesktop.systemd1.manage-units + + diff --git a/scripts/fenris b/scripts/fenris new file mode 100755 index 0000000..3c34172 --- /dev/null +++ b/scripts/fenris @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""fenris: unprivileged entry point for the Fenris TUI and CLI. + +With no arguments, opens the TUI. +Subcommands route through fenris-monitor for privileged operations. + +Spec: §1.2, §8.4 +""" +import argparse +import os +import subprocess +import sys + + +def is_root() -> bool: + """Check if running as root.""" + return os.geteuid() == 0 + + +def run_monitor(*args: str) -> None: + """Run fenris-monitor with the given arguments. + + If not root, re-exec under pkexec. + """ + monitor_cmd = "/usr/libexec/fenris/fenris-monitor" + + if is_root(): + result = subprocess.run([monitor_cmd] + list(args)) + sys.exit(result.returncode) + else: + # Use pkexec to elevate + pkexec = subprocess.run( + ["which", "pkexec"], capture_output=True + ) + if pkexec.returncode != 0: + print( + "Error: No polkit agent available. " + "Run as root: sudo fenris-monitor ...", + file=sys.stderr, + ) + sys.exit(1) + result = subprocess.run(["pkexec", monitor_cmd] + list(args)) + sys.exit(result.returncode) + + +def cmd_tui(args: argparse.Namespace) -> None: + """Open the TUI.""" + from fenris.tui import run_tui + run_tui() + + +def cmd_status(args: argparse.Namespace) -> None: + """Show status.""" + from fenris.status import print_status + print_status() + + +def cmd_sample(args: argparse.Namespace) -> None: + """Trigger on-demand collection.""" + run_monitor("collect") + + +def cmd_monitor_pause(args: argparse.Namespace) -> None: + """Pause monitoring.""" + # Pause asks confirmation (§7.4) + if not args.yes: + response = input("Pause monitoring? [y/N] ") + if response.lower() not in ("y", "yes"): + print("Aborted.") + return + + run_monitor("disable", "--now") + + +def cmd_monitor_resume(args: argparse.Namespace) -> None: + """Resume monitoring.""" + # Resume does not ask confirmation (§7.4) + run_monitor("enable", "--now") + + +def cmd_baseline_set(args: argparse.Namespace) -> None: + """Set baseline.""" + run_monitor("baseline", "set", args.baseline_json) + + +def cmd_baseline_clear(args: argparse.Namespace) -> None: + """Clear baseline.""" + run_monitor("baseline", "clear") + + +def cmd_import(args: argparse.Namespace) -> None: + """Import legacy history.""" + # This is a one-off migration, not a privileged operation + print("Legacy import: use fenris-import directly") + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="fenris", + description="Fenris NVMe endurance monitor", + ) + parser.add_argument( + "--version", action="version", version="%(prog)s 0.3.0" + ) + + subparsers = parser.add_subparsers(dest="command") + + # Default: TUI (no subcommand) + subparsers.add_parser("tui", help="Open the TUI (default)") + + # Status + subparsers.add_parser("status", help="Show status") + + # Sample (on-demand collection) + subparsers.add_parser("sample", help="Trigger on-demand collection") + + # Monitor subcommand + monitor_parser = subparsers.add_parser("monitor", help="Monitor control") + monitor_sub = monitor_parser.add_subparsers(dest="monitor_action") + + # monitor pause + pause_parser = monitor_sub.add_parser("pause", help="Pause monitoring") + pause_parser.add_argument( + "-y", "--yes", action="store_true", help="Skip confirmation" + ) + pause_parser.set_defaults(func=cmd_monitor_pause) + + # monitor resume + resume_parser = monitor_sub.add_parser("resume", help="Resume monitoring") + resume_parser.set_defaults(func=cmd_monitor_resume) + + # Baseline subcommand + baseline_parser = subparsers.add_parser("baseline", help="Baseline operations") + baseline_sub = baseline_parser.add_subparsers(dest="baseline_action") + + baseline_set = baseline_sub.add_parser("set", help="Set 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) + + # Import + import_parser = subparsers.add_parser("import", help="Import legacy history") + import_parser.add_argument("path", help="Path to history.jsonl") + import_parser.set_defaults(func=cmd_import) + + # Rejected commands + for cmd in ["start", "stop", "run"]: + reject_parser = subparsers.add_parser(cmd, help=argparse.SUPPRESS) + reject_parser.set_defaults(func=lambda a: print( + f"'{cmd}' is not a valid command. " + f"Use 'fenris monitor resume' instead.", + file=sys.stderr, + )) + + args = parser.parse_args() + + if args.command is None or args.command == "tui": + cmd_tui(args) + elif args.command == "status": + cmd_status(args) + elif args.command == "sample": + cmd_sample(args) + elif args.command == "monitor": + if args.monitor_action is None: + monitor_parser.error("a subcommand is required") + args.func(args) + elif args.command == "baseline": + if args.baseline_action is None: + baseline_parser.error("a subcommand is required") + args.func(args) + elif args.command == "import": + cmd_import(args) + + +if __name__ == "__main__": + main() diff --git a/src/fenris/collect.py b/src/fenris/collect.py new file mode 100644 index 0000000..1285d9d --- /dev/null +++ b/src/fenris/collect.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""fenris-collect: device interrogation and store writes. + +This is the root oneshot unit's ExecStart. It reads the device selector +from /etc/fenris/fenris.conf, interrogates the drive via smartctl and sysfs, +and writes the sample to the observation store. + +Spec: §8.4, §8.5 + +When run as a script, uses the fenris package from the installed wheel. +""" +import json +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +# Add the venv to path if running from the installed location +VENV_DIR = Path("/opt/fenris") +if VENV_DIR.exists(): + 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.collector import run_collection + + +CONFIG_PATH = Path("/etc/fenris/fenris.conf") + + +def load_config() -> dict: + """Load configuration from /etc/fenris/fenris.conf. + + The file holds exactly one key: the device selector. + Spec §8.3: re-read every run; no reload path. + """ + if not CONFIG_PATH.exists(): + raise RuntimeError(f"Configuration file not found: {CONFIG_PATH}") + + config = {} + try: + with open(CONFIG_PATH, "r") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + key, value = line.split("=", 1) + config[key.strip()] = value.strip() + except Exception as e: + raise RuntimeError(f"Failed to read configuration: {e}") + + if "device" not in config: + raise RuntimeError("Configuration error: missing 'device' key") + + return config + + +def interrogate_drive(device: str) -> dict: + """Interrogate the drive via smartctl. + + Returns the smartctl JSON output. + Raises RuntimeError on failure. + """ + result = subprocess.run( + ["smartctl", "-a", "-j", device], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + raise RuntimeError( + f"smartctl failed for {device}: {result.stderr}" + ) + + try: + return json.loads(result.stdout) + except json.JSONDecodeError as e: + raise RuntimeError(f"Failed to parse smartctl output: {e}") + + +def find_nvme_sysfs() -> Path | None: + """Find the NVMe controller sysfs path.""" + nvme_ctrl = Path("/sys/class/nvme") + if not nvme_ctrl.exists(): + return None + + for ctrl in sorted(nvme_ctrl.iterdir()): + if ctrl.name.startswith("nvme"): + return ctrl + return None + + +def main() -> None: + """Run one collection cycle.""" + try: + config = load_config() + device = config["device"] + + # Interrogate the drive + smartctl_data = interrogate_drive(device) + + # Find sysfs path + sysfs_path = find_nvme_sysfs() + if sysfs_path is None: + raise RuntimeError("No NVMe controller found in sysfs") + + # Inject a simple clock + class SimpleClock: + def utcnow(self): + return datetime.now(timezone.utc) + + clock = SimpleClock() + + # Run collection + result = run_collection(smartctl_data, sysfs_path, config, clock) + + if result["ok"]: + print(f"Collection successful: {result['sample_count']} sample(s)") + sys.exit(0) + else: + print(f"Collection failed: {result['error']}", file=sys.stderr) + sys.exit(1) + + except Exception as e: + print(f"Collection error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/fenris/monitor.py b/src/fenris/monitor.py new file mode 100644 index 0000000..1cc0ad8 --- /dev/null +++ b/src/fenris/monitor.py @@ -0,0 +1,282 @@ +#!/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 subprocess +import sys +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path + +# Add the venv to path if running from the installed location +VENV_DIR = Path("/opt/fenris") +if VENV_DIR.exists(): + 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, +) + + +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) + 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: + # 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 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 "")) + 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 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 "")) + finally: + conn.close() + + +def cmd_collect(args: argparse.Namespace) -> None: + """Trigger on-demand collection. + + Starts fenris-collect.service, 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. + """ + 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 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() diff --git a/tests/test_monitor.py b/tests/test_monitor.py new file mode 100644 index 0000000..48e9217 --- /dev/null +++ b/tests/test_monitor.py @@ -0,0 +1,310 @@ +"""Tests for fenris-monitor helper. + +Spec: §8.4, §8.5, §8.6, §8.7 +""" +import json +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import patch, MagicMock + +import pytest + +import sys +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from fenris.monitor import ( + cmd_enable, + cmd_disable, + cmd_collect, + cmd_baseline_set, + cmd_baseline_clear, + is_root, +) +from fenris.store import init_store + + +@pytest.fixture +def tmp_store(tmp_path): + """Create a temporary observation store.""" + store_path = tmp_path / "observations.db" + conn = init_store(store_path) + yield conn + conn.close() + + +@pytest.fixture +def store_path(tmp_path): + """Return path to a temporary observation store.""" + return tmp_path / "observations.db" + + +class TestIsRoot: + def test_root_returns_true(self): + with patch("os.geteuid", return_value=0): + assert is_root() is True + + def test_non_root_returns_false(self): + with patch("os.geteuid", return_value=1000): + assert is_root() is False + + +class TestEnableIdempotentMatrix: + """§8.6: Period-row idempotent matrix.""" + + def test_first_opens_period(self, store_path): + """First-ever enable opens a period at the enable moment.""" + # Initialize store + init_store(store_path) + + args = MagicMock(now=False, store_path=store_path) + + with patch("fenris.monitor.subprocess") as mock_sub: + mock_sub.run.return_value = MagicMock(returncode=0) + cmd_enable(args) + + # Period should be open + conn = init_store(store_path) + cursor = conn.execute( + "SELECT ended_at FROM monitoring_periods WHERE ended_at IS NULL" + ) + assert cursor.fetchone() is not None + conn.close() + + def test_resume_with_open_period_noop(self, store_path): + """Resume with open period: no-op (gap stays inside as unknown).""" + # Initialize store and open a period + conn = init_store(store_path) + now = datetime.now(timezone.utc) + conn.execute( + "INSERT INTO monitoring_periods (started_at) VALUES (?)", + (now.isoformat(),), + ) + conn.commit() + conn.close() + + args = MagicMock(now=True, store_path=store_path) + + with patch("fenris.monitor.subprocess") as mock_sub: + mock_sub.run.return_value = MagicMock(returncode=0) + cmd_enable(args) + + # Should still have exactly one open period + conn = init_store(store_path) + cursor = conn.execute( + "SELECT COUNT(*) FROM monitoring_periods WHERE ended_at IS NULL" + ) + assert cursor.fetchone()[0] == 1 + conn.close() + + def test_resume_with_no_period_opens_new(self, store_path): + """Resume with no open period opens a new row.""" + # Initialize store and close any existing period + conn = init_store(store_path) + conn.execute( + "UPDATE monitoring_periods SET ended_at = ?, end_cause = ?", + (datetime.now(timezone.utc).isoformat(), "user_disabled"), + ) + conn.commit() + conn.close() + + args = MagicMock(now=True, store_path=store_path) + + with patch("fenris.monitor.subprocess") as mock_sub: + mock_sub.run.return_value = MagicMock(returncode=0) + cmd_enable(args) + + # Should have a new open period + conn = init_store(store_path) + cursor = conn.execute( + "SELECT COUNT(*) FROM monitoring_periods WHERE ended_at IS NULL" + ) + assert cursor.fetchone()[0] == 1 + conn.close() + + +class TestDisableIdempotentMatrix: + """§8.6: Period-row idempotent matrix.""" + + def test_pause_with_open_period_closes_user_disabled(self, store_path): + """Pause with open period closes it user_disabled.""" + # Initialize store and open a period + conn = init_store(store_path) + now = datetime.now(timezone.utc) + conn.execute( + "INSERT INTO monitoring_periods (started_at) VALUES (?)", + (now.isoformat(),), + ) + conn.commit() + conn.close() + + args = MagicMock(now=True, store_path=store_path) + + with patch("fenris.monitor.subprocess") as mock_sub: + mock_sub.run.return_value = MagicMock(returncode=0) + cmd_disable(args) + + # Period should be closed with user_disabled + conn = init_store(store_path) + cursor = conn.execute( + "SELECT end_cause FROM monitoring_periods WHERE ended_at IS NOT NULL" + ) + assert cursor.fetchone()[0] == "user_disabled" + conn.close() + + def test_pause_without_open_period_noop(self, store_path): + """Pause otherwise no-ops.""" + # Initialize store + init_store(store_path) + + args = MagicMock(now=True, store_path=store_path) + + with patch("fenris.monitor.subprocess") as mock_sub: + mock_sub.run.return_value = MagicMock(returncode=0) + cmd_disable(args) + + # No periods should exist + conn = init_store(store_path) + cursor = conn.execute("SELECT COUNT(*) FROM monitoring_periods") + assert cursor.fetchone()[0] == 0 + conn.close() + + def test_raw_systemctl_stop_never_records_user_disabled(self, store_path): + """Raw systemctl stop outside helper never records user_disabled.""" + # Initialize store and open a period + conn = init_store(store_path) + now = datetime.now(timezone.utc) + conn.execute( + "INSERT INTO monitoring_periods (started_at) VALUES (?)", + (now.isoformat(),), + ) + conn.commit() + + # Simulate raw systemctl stop (no monitor involved) + # The period stays open - only the sanctioned path closes it + cursor = conn.execute( + "SELECT end_cause FROM monitoring_periods WHERE ended_at IS NULL" + ) + assert cursor.fetchone() is not None # Still open + conn.close() + + +class TestCollectTrigger: + """§8.7: On-demand collection via helper path.""" + + def test_collect_triggers_systemctl_start(self): + """Collect starts fenris-collect.service synchronously.""" + args = MagicMock() + + with patch("fenris.monitor.subprocess") as mock_sub: + mock_sub.run.return_value = MagicMock(returncode=0) + cmd_collect(args) + + mock_sub.run.assert_called_once_with( + ["systemctl", "start", "fenris-collect.service"], + capture_output=True, + text=True, + ) + + 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 pytest.raises(SystemExit) as exc_info: + cmd_collect(args) + assert exc_info.value.code == 1 + + +class TestBaselinePersistence: + """PR-14: Baseline persistence behind polkit-guarded helper.""" + + def test_baseline_set_persists(self, store_path): + """baseline set persists the baseline row.""" + # Initialize store + init_store(store_path) + + args = MagicMock( + store_path=store_path, + baseline_json=json.dumps( + { + "tbw_terabytes": 600, + "source_url": "https://example.com/spec", + "document_revision": "rev1", + "entry_date": "2024-01-01", + "model_string": "Samsung 990 Pro", + "nominal_capacity_bytes": 2000000000000, + } + ) + ) + + cmd_baseline_set(args) + + conn = init_store(store_path) + cursor = conn.execute("SELECT * FROM endurance_baseline") + row = cursor.fetchone() + assert row is not None + assert row[1] == 600.0 # tbw_terabytes + conn.close() + + def test_baseline_set_replaces_existing(self, store_path): + """baseline set replaces any existing baseline.""" + # Initialize store and insert initial baseline + conn = init_store(store_path) + now = datetime.now(timezone.utc) + conn.execute( + "INSERT INTO endurance_baseline (tbw_terabytes, source_url, " + "document_revision, entry_date, model_string, nominal_capacity_bytes, " + "created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (400, "old", "v1", "2023-01-01", "Old Model", 1000000000000, + now.isoformat(), now.isoformat()), + ) + conn.commit() + conn.close() + + args = MagicMock( + store_path=store_path, + baseline_json=json.dumps( + { + "tbw_terabytes": 600, + "source_url": "new", + "document_revision": "v2", + "entry_date": "2024-01-01", + "model_string": "New Model", + "nominal_capacity_bytes": 2000000000000, + } + ) + ) + + cmd_baseline_set(args) + + conn = init_store(store_path) + cursor = conn.execute("SELECT COUNT(*) FROM endurance_baseline") + assert cursor.fetchone()[0] == 1 # Only one row + conn.close() + + def test_baseline_clear_removes(self, store_path): + """baseline clear removes the baseline.""" + # Initialize store and insert baseline + conn = init_store(store_path) + now = datetime.now(timezone.utc) + conn.execute( + "INSERT INTO endurance_baseline (tbw_terabytes, source_url, " + "document_revision, entry_date, model_string, nominal_capacity_bytes, " + "created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (400, "src", "v1", "2024-01-01", "Model", 1000000000000, + now.isoformat(), now.isoformat()), + ) + conn.commit() + conn.close() + + args = MagicMock(store_path=store_path) + cmd_baseline_clear(args) + + conn = init_store(store_path) + cursor = conn.execute("SELECT COUNT(*) FROM endurance_baseline") + assert cursor.fetchone()[0] == 0 + conn.close() diff --git a/units/fenris-collect.service b/units/fenris-collect.service new file mode 100644 index 0000000..e2dffb3 --- /dev/null +++ b/units/fenris-collect.service @@ -0,0 +1,9 @@ +[Unit] +Description=Fenris NVMe collection service +Documentation=https://git.bongbetic.com/xavierk/Fenris +After=local-fs.target + +[Service] +Type=oneshot +ExecStart=/usr/libexec/fenris/fenris-collect +TimeoutStartSec=90 diff --git a/units/fenris-collect.timer b/units/fenris-collect.timer new file mode 100644 index 0000000..407ee0b --- /dev/null +++ b/units/fenris-collect.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Fenris collection timer +Documentation=https://git.bongbetic.com/xavierk/Fenris + +[Timer] +OnBootSec=2min +OnUnitInactiveSec=5min +AccuracySec=30s +Persistent=no + +[Install] +WantedBy=timers.target