feat: ship fenris-monitor helper, polkit policy, and systemd units (#29)
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE policyconfig PUBLIC
|
||||||
|
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
|
||||||
|
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
|
||||||
|
<policyconfig>
|
||||||
|
<vendor>bongbetic</vendor>
|
||||||
|
<vendor_url>https://bongbetic.com</vendor_url>
|
||||||
|
|
||||||
|
<action id="com.bongbetic.fenris.monitor">
|
||||||
|
<description>Fenris Monitor Helper</description>
|
||||||
|
<message>Authentication is required to manage Fenris monitoring.</message>
|
||||||
|
|
||||||
|
<defaults>
|
||||||
|
<allow_any>no</allow_any>
|
||||||
|
<allow_inactive>no</allow_inactive>
|
||||||
|
<allow_active>auth_admin</allow_active>
|
||||||
|
</defaults>
|
||||||
|
|
||||||
|
<annotate key="org.freedesktop.policykit.imply">org.freedesktop.systemd1.manage-units</annotate>
|
||||||
|
</action>
|
||||||
|
</policyconfig>
|
||||||
Executable
+178
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user