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