feat: ship fenris-monitor helper, polkit policy, and systemd units (#29)
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user