138 lines
3.8 KiB
Python
138 lines
3.8 KiB
Python
#!/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()
|