feat: collector tracer bullet (#21)\n\nSmartctl acquisition with validation\nSysfs controller identity acquisition\nIdentity normalization (strip, no case fold, blank handling)\nSQLite store in WAL mode with six entities\nSchema versioning via PRAGMA user_version\nInvariant validation (negative bytes, etc.)\n17 passing tests across two test files\n\nCloses #21

This commit is contained in:
xavierk
2026-09-01 22:33:26 +05:30
parent 566d1c81b1
commit 2217b00ff6
6 changed files with 946 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
"""Fenris: NVMe wear monitor with persistent TUI."""
__version__ = "0.3.0"
+281
View File
@@ -0,0 +1,281 @@
"""Collector: acquires counters and identity, writes to observation store.
This module implements the thinnest complete write path:
- Acquire counters and thermal evidence from smartctl -a -j
- Acquire controller identity from sysfs
- Normalize identity exactly once at write time
- Validate every row against store invariants
- Commit one well-formed sample
No code path outside the collector interrogates the device.
"""
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from .store import init_store, get_store_path
class AcquisitionError(Exception):
"""Raised when acquisition fails - whole run is refused."""
pass
class InvariantViolationError(Exception):
"""Raised when a row would violate store invariants - writes nothing."""
pass
def acquire_from_smartctl(smartctl_data: Dict[str, Any]) -> Dict[str, Any]:
"""Acquire counters and thermal evidence from smartctl -a -j data.
Validates that all required fields are present.
Raises AcquisitionError on any failure.
"""
required_fields = [
"nvme_smart_health_information_log",
"user_capacity",
"model_name",
"serial_number",
"firmware_version",
]
for field in required_fields:
if field not in smartctl_data:
raise AcquisitionError(f"Missing required field in smartctl data: {field}")
log = smartctl_data["nvme_smart_health_information_log"]
required_log_fields = [
"data_units_written",
"data_units_read",
"percentage_used",
"power_on_hours",
"temperature",
]
for field in required_log_fields:
if field not in log:
raise AcquisitionError(f"Missing required field in SMART log: {field}")
return {
"model": smartctl_data["model_name"],
"serial": smartctl_data["serial_number"],
"firmware_rev": smartctl_data["firmware_version"],
"capacity_bytes": smartctl_data["user_capacity"]["bytes"],
"percentage_used": log["percentage_used"],
"available_spare": log.get("available_spare"),
"media_errors": log.get("media_errors", 0),
"power_on_hours": log["power_on_hours"],
"power_cycles": log.get("power_cycles"),
"unsafe_shutdowns": log.get("unsafe_shutdowns"),
"temperature_c": log["temperature"],
"data_units_written": log["data_units_written"],
"data_units_read": log["data_units_read"],
"bytes_written": log["data_units_written"] * 512000,
"bytes_read": log["data_units_read"] * 512000,
"critical_warning": log.get("critical_warning", 0),
}
def acquire_from_sysfs(sysfs_path: Path) -> Dict[str, Any]:
"""Acquire controller identity from sysfs.
Reads identity from:
- /sys/class/nvme/<ctrl>/subsysnqn (primary)
- /sys/class/nvme/<ctrl>/model
- /sys/class/nvme/<ctrl>/serial
- /sys/class/nvme/<ctrl>/firmware_rev
- /sys/class/nvme/<ctrl>/transport/ (optional)
Raises AcquisitionError on any failure.
"""
identity_files = {
"subnqn": "subsysnqn",
"mn": "model",
"sn": "serial",
"fr": "firmware_rev",
}
identity = {}
for key, filename in identity_files.items():
filepath = sysfs_path / filename
if not filepath.exists():
raise AcquisitionError(f"Missing sysfs file: {filepath}")
try:
value = filepath.read_text().strip()
identity[key] = value if value else ""
except Exception as e:
raise AcquisitionError(f"Failed to read {filepath}: {e}")
# Transport info (optional)
transport_dir = sysfs_path / "transport"
if transport_dir.exists():
try:
transport_file = transport_dir / "trstring"
if transport_file.exists():
identity["transport"] = transport_file.read_text().strip()
else:
identity["transport"] = None
except Exception:
identity["transport"] = None
else:
identity["transport"] = None
return identity
def normalize_identity(identity: Dict[str, Any]) -> str:
"""Normalize identity exactly once at write time.
Rules:
- Strip trailing spaces and newlines
- No case folding
- Empty-after-strip stored blank
Returns normalized identity key.
"""
# Primary key: normalized kernel-exposed subsystem NQN
key = identity.get("subnqn", "")
if key:
key = key.rstrip()
return key
# Fallback 1: kernel composite (not implemented yet)
# Fallback 2: model|serial
mn = identity.get("mn", "").rstrip()
sn = identity.get("sn", "").rstrip()
if mn or sn:
return f"{mn}|{sn}"
# All keys blank - degraded identity
return ""
def compute_identity_degraded(identity: Dict[str, Any]) -> bool:
"""Check if identity is degraded (all key rungs empty)."""
key = normalize_identity(identity)
return key == ""
def validate_sample_invariants(sample: Dict[str, Any], conn: sqlite3.Connection) -> None:
"""Validate sample against store invariants.
Raises InvariantViolationError if any invariant is violated.
"""
# TODO: Implement more complex invariants as needed
# For now, just check basic constraints
if sample.get("bytes_written", 0) < 0:
raise InvariantViolationError("Negative bytes_written")
if sample.get("bytes_read", 0) < 0:
raise InvariantViolationError("Negative bytes_read")
def write_sample(
sample: Dict[str, Any],
identity: Dict[str, Any],
conn: sqlite3.Connection,
clock,
) -> None:
"""Write one sample to the observation store.
Identity normalization happens exactly once here.
"""
# Normalize identity exactly once at write time
identity_key = normalize_identity(identity)
identity_degraded = compute_identity_degraded(identity)
# TODO: Implement full sample writing with controller segment handling
# For now, just insert a basic sample with identity fields
cursor = conn.execute(
"""
INSERT INTO samples (
ts, device, subnqn, sn, mn, fr, capacity_bytes,
percentage_used, available_spare, media_errors, power_on_hours,
power_cycles, unsafe_shutdowns, temperature_c,
data_units_written, data_units_read, bytes_written, bytes_read,
critical_warning
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
sample["ts"],
sample["device"],
identity.get("subnqn", ""),
identity.get("sn", ""),
identity.get("mn", ""),
identity.get("fr", ""),
sample["capacity_bytes"],
sample["percentage_used"],
sample["available_spare"],
sample["media_errors"],
sample["power_on_hours"],
sample["power_cycles"],
sample["unsafe_shutdowns"],
sample["temperature_c"],
sample["data_units_written"],
sample["data_units_read"],
sample["bytes_written"],
sample["bytes_read"],
sample["critical_warning"],
),
)
conn.commit()
def run_collection(
smartctl_data: Dict[str, Any],
sysfs_path: Path,
config: Dict[str, Any],
clock,
) -> Dict[str, Any]:
"""Run one collection run.
This is the main entry point for the collector.
Returns the run outcome.
"""
try:
# Acquire counters and thermal evidence
counters = acquire_from_smartctl(smartctl_data)
# Acquire controller identity
identity = acquire_from_sysfs(sysfs_path)
# Build sample with injected clock
sample = {
"ts": clock.utcnow().isoformat(),
"device": config["device"],
**counters,
**identity,
}
# Initialize store if needed
store_path = get_store_path(config)
conn = init_store(store_path)
try:
# Validate invariants
validate_sample_invariants(sample, conn)
# Write sample
write_sample(sample, identity, conn, clock)
return {
"ok": True,
"sample_count": 1,
"store_path": str(store_path),
}
finally:
conn.close()
except (AcquisitionError, InvariantViolationError) as e:
return {
"ok": False,
"error": str(e),
"error_type": type(e).__name__,
}
+187
View File
@@ -0,0 +1,187 @@
"""Observation store: SQLite database for persisting observation history.
This module handles:
- Store initialization with WAL mode
- Schema versioning with PRAGMA user_version
- The six entities: samples, hour_observations, day_aggregates,
monitoring_periods, controller_segments, endurance_baseline
"""
import sqlite3
from pathlib import Path
from typing import Optional
# Schema version - increment on each migration
SCHEMA_VERSION = 1
def get_store_path(config: dict) -> Path:
"""Get the store path from config."""
return Path(config["store_path"])
def init_store(store_path: Path) -> sqlite3.Connection:
"""Initialize the observation store if not present.
Creates the database with WAL mode and all six entities.
Returns a connection to the store.
"""
conn = sqlite3.connect(str(store_path))
# Enable WAL mode for concurrent reads during writes
conn.execute("PRAGMA journal_mode=WAL")
# Check if this is a new database
cursor = conn.execute("PRAGMA user_version")
current_version = cursor.fetchone()[0]
if current_version == 0:
# New database - create schema
_create_schema(conn)
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
conn.commit()
elif current_version > SCHEMA_VERSION:
# Unknown newer version - refuse
conn.close()
raise ValueError(
f"Observation store written by a newer Fenris (version {current_version}) "
f"— upgrade Fenris"
)
elif current_version < SCHEMA_VERSION:
# Older version - apply migrations
_apply_migrations(conn, current_version)
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
conn.commit()
return conn
def _create_schema(conn: sqlite3.Connection):
"""Create the initial schema with all six entities."""
# Samples: raw collection runs (14-day retention)
conn.execute("""
CREATE TABLE IF NOT EXISTS samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL, -- ISO 8601 UTC timestamp
device TEXT NOT NULL,
-- Normalized controller-identity fields captured at acquisition
subnqn TEXT,
sn TEXT,
mn TEXT,
fr TEXT,
capacity_bytes INTEGER,
percentage_used INTEGER,
available_spare INTEGER,
media_errors INTEGER,
power_on_hours INTEGER,
power_cycles INTEGER,
unsafe_shutdowns INTEGER,
temperature_c INTEGER,
data_units_written INTEGER,
data_units_read INTEGER,
bytes_written INTEGER,
bytes_read INTEGER,
critical_warning INTEGER
)
""")
# Hour observations: UTC-hour usage-habit split
conn.execute("""
CREATE TABLE IF NOT EXISTS hour_observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hour TEXT NOT NULL UNIQUE, -- ISO 8601 UTC hour (e.g., "2026-09-01T12:00:00Z")
active_seconds INTEGER DEFAULT 0,
idle_seconds INTEGER DEFAULT 0,
powered_off_seconds INTEGER DEFAULT 0,
unknown_seconds INTEGER DEFAULT 0,
bytes_written_delta INTEGER DEFAULT 0,
bytes_read_delta INTEGER DEFAULT 0,
temperature_min INTEGER,
temperature_avg REAL,
temperature_max INTEGER,
sample_count INTEGER DEFAULT 0,
coverage REAL DEFAULT 0.0
)
""")
# Day aggregates: derived from hour observations
conn.execute("""
CREATE TABLE IF NOT EXISTS day_aggregates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day TEXT NOT NULL UNIQUE, -- ISO 8601 UTC day (e.g., "2026-09-01")
active_seconds INTEGER DEFAULT 0,
idle_seconds INTEGER DEFAULT 0,
powered_off_seconds INTEGER DEFAULT 0,
unknown_seconds INTEGER DEFAULT 0,
bytes_written_delta INTEGER DEFAULT 0,
bytes_read_delta INTEGER DEFAULT 0,
sample_count INTEGER DEFAULT 0,
coverage REAL DEFAULT 0.0
)
""")
# Monitoring periods: tracking when monitoring was enabled/disabled
conn.execute("""
CREATE TABLE IF NOT EXISTS monitoring_periods (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL, -- ISO 8601 UTC timestamp
ended_at TEXT, -- NULL if currently active
end_cause TEXT CHECK(end_cause IN ('user_disabled', 'migrated', 'unknown_gap'))
)
""")
# Controller segments: identity key plus metadata snapshot
conn.execute("""
CREATE TABLE IF NOT EXISTS controller_segments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
opened_at TEXT NOT NULL, -- ISO 8601 UTC timestamp
identity_key TEXT, -- Normalized identity key (NULL if degraded)
identity_degraded BOOLEAN DEFAULT 0,
subnqn TEXT,
sn TEXT,
mn TEXT,
fr TEXT,
vid TEXT,
ssvid TEXT,
transport TEXT
)
""")
# Endurance baseline: one active row, replaced on edit
conn.execute("""
CREATE TABLE IF NOT EXISTS endurance_baseline (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tbw_terabytes REAL NOT NULL,
source_url TEXT,
document_revision TEXT,
entry_date TEXT,
model_string TEXT,
nominal_capacity_bytes INTEGER,
validated_by TEXT, -- 'user' or 'machine_match'
verified BOOLEAN DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
def _apply_migrations(conn: sqlite3.Connection, current_version: int):
"""Apply forward-only migrations from current_version to SCHEMA_VERSION."""
# Future migrations will go here
# For now, just upgrade to current version
pass
def is_store_faulty(store_path: Path) -> bool:
"""Check if the store is present but cannot be read or trusted."""
if not store_path.exists():
return False
try:
conn = sqlite3.connect(f"file:{store_path}?mode=ro", uri=True)
conn.execute("PRAGMA user_version")
conn.close()
return False
except sqlite3.Error:
return True