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:
@@ -0,0 +1,358 @@
|
||||
"""Collector tracer bullet test.
|
||||
|
||||
Tests the thinnest complete write path through the system:
|
||||
- Input: smartctl-JSON fixture, sysfs fixture tree, config fixture, injected clock
|
||||
- Output: resulting store contents, run outcome
|
||||
|
||||
Seam: write side of the observation store database file.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Generator
|
||||
|
||||
import pytest
|
||||
|
||||
# Add src to path for imports
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from fenris.collector import run_collection, AcquisitionError, InvariantViolationError
|
||||
from fenris.store import init_store, get_store_path
|
||||
|
||||
|
||||
# Fixtures
|
||||
|
||||
@pytest.fixture
|
||||
def smartctl_fixture() -> Dict[str, Any]:
|
||||
"""Minimal smartctl -a -j output with required fields."""
|
||||
return {
|
||||
"json_format_version": [1, 0],
|
||||
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||
"nvme_smart_health_information_log": {
|
||||
"critical_warning": 0,
|
||||
"temperature": 35,
|
||||
"available_spare": 100,
|
||||
"available_spare_threshold": 10,
|
||||
"percentage_used": 5,
|
||||
"data_units_written": 12345678,
|
||||
"data_units_read": 9876543,
|
||||
"power_on_hours": 8765,
|
||||
"power_cycles": 1234,
|
||||
"unsafe_shutdowns": 5,
|
||||
"media_errors": 0,
|
||||
"num_err_log_entries": 0,
|
||||
},
|
||||
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||
"serial_number": "S4EWNX0N123456",
|
||||
"firmware_version": "2B2QEXM7",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sysfs_fixture_tree(tmp_path: Path) -> Path:
|
||||
"""Create a minimal sysfs fixture tree with controller identity."""
|
||||
ctrl_dir = tmp_path / "sys" / "class" / "nvme" / "nvme0"
|
||||
ctrl_dir.mkdir(parents=True)
|
||||
|
||||
# Controller identity files
|
||||
(ctrl_dir / "subsysnqn").write_text("nqn.2014-08.org.nvmexpress:uuid:12345678-1234-1234-1234-123456789abc\n")
|
||||
(ctrl_dir / "model").write_text("Samsung SSD 970 EVO Plus 1TB\n")
|
||||
(ctrl_dir / "serial").write_text("S4EWNX0N123456\n")
|
||||
(ctrl_dir / "firmware_rev").write_text("2B2QEXM7\n")
|
||||
|
||||
# Transport info (optional, but we'll include it)
|
||||
transport_dir = ctrl_dir / "transport"
|
||||
transport_dir.mkdir()
|
||||
(transport_dir / "address").write_text("0000:03:00.0")
|
||||
(transport_dir / "trstring").write_text("pcie")
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_fixture(tmp_path: Path) -> Dict[str, Any]:
|
||||
"""Configuration fixture naming the device."""
|
||||
return {
|
||||
"device": "/dev/nvme0",
|
||||
"store_path": str(tmp_path / "observations.db"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock_fixture():
|
||||
"""Injected clock returning fixed time."""
|
||||
class FakeClock:
|
||||
def __init__(self):
|
||||
self.now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
def utcnow(self):
|
||||
return self.now
|
||||
|
||||
return FakeClock()
|
||||
|
||||
|
||||
# Test: Collector writes one well-formed sample
|
||||
|
||||
def test_collector_writes_one_sample(
|
||||
smartctl_fixture: Dict[str, Any],
|
||||
sysfs_fixture_tree: Path,
|
||||
config_fixture: Dict[str, Any],
|
||||
clock_fixture,
|
||||
):
|
||||
"""Given a smartctl fixture and sysfs fixture tree,
|
||||
when the collector runs,
|
||||
then one well-formed sample is written to the observation store."""
|
||||
result = run_collection(
|
||||
smartctl_data=smartctl_fixture,
|
||||
sysfs_path=sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||
config=config_fixture,
|
||||
clock=clock_fixture,
|
||||
)
|
||||
|
||||
# Verify run succeeded
|
||||
assert result["ok"] is True, f"Collection failed: {result.get('error')}"
|
||||
|
||||
# Verify store contents
|
||||
conn = sqlite3.connect(config_fixture["store_path"])
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM samples")
|
||||
count = cursor.fetchone()[0]
|
||||
assert count == 1
|
||||
|
||||
cursor = conn.execute("SELECT * FROM samples")
|
||||
row = cursor.fetchone()
|
||||
assert row is not None
|
||||
|
||||
# Verify row contents match fixtures
|
||||
# Row structure: id, 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
|
||||
assert row[2] == "/dev/nvme0" # device
|
||||
assert row[3] == "nqn.2014-08.org.nvmexpress:uuid:12345678-1234-1234-1234-123456789abc" # subnqn
|
||||
assert row[4] == "S4EWNX0N123456" # sn
|
||||
assert row[5] == "Samsung SSD 970 EVO Plus 1TB" # mn
|
||||
assert row[6] == "2B2QEXM7" # fr
|
||||
assert row[7] == 1024000000000 # capacity_bytes
|
||||
assert row[8] == 5 # percentage_used
|
||||
assert row[15] == 12345678 # data_units_written
|
||||
assert row[17] == 12345678 * 512000 # bytes_written
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
# Test: Identity normalization applied exactly once at write time
|
||||
|
||||
def test_identity_normalization(
|
||||
smartctl_fixture: Dict[str, Any],
|
||||
sysfs_fixture_tree: Path,
|
||||
config_fixture: Dict[str, Any],
|
||||
clock_fixture,
|
||||
):
|
||||
"""Given sysfs identity with trailing spaces/newlines,
|
||||
when the collector writes,
|
||||
then identity is normalized exactly once at write time."""
|
||||
# Create identity with trailing whitespace
|
||||
identity = {
|
||||
"subnqn": "nqn.2014-08.org.nvmexpress:uuid:12345678-1234-1234-1234-123456789abc \n",
|
||||
"mn": "Samsung SSD 970 EVO Plus 1TB\n",
|
||||
"sn": "S4EWNX0N123456\n",
|
||||
"fr": "2B2QEXM7",
|
||||
"transport": "pcie",
|
||||
}
|
||||
|
||||
from fenris.collector import normalize_identity
|
||||
|
||||
# Normalize once
|
||||
key1 = normalize_identity(identity)
|
||||
# Normalize again - should be identical
|
||||
key2 = normalize_identity(identity)
|
||||
|
||||
assert key1 == key2
|
||||
assert key1 == "nqn.2014-08.org.nvmexpress:uuid:12345678-1234-1234-1234-123456789abc"
|
||||
assert "\n" not in key1
|
||||
assert key1 == key1.rstrip() # No trailing whitespace
|
||||
|
||||
|
||||
# Test: Any acquisition failure fails the whole run
|
||||
|
||||
def test_acquisition_failure_fails_run(
|
||||
sysfs_fixture_tree: Path,
|
||||
config_fixture: Dict[str, Any],
|
||||
clock_fixture,
|
||||
):
|
||||
"""Given a smartctl fixture with missing fields,
|
||||
when the collector runs,
|
||||
then the whole run fails and writes nothing."""
|
||||
# Missing required field
|
||||
bad_smartctl = {
|
||||
"json_format_version": [1, 0],
|
||||
"smartctl": {"version": [7, 3]},
|
||||
# Missing nvme_smart_health_information_log
|
||||
}
|
||||
|
||||
result = run_collection(
|
||||
smartctl_data=bad_smartctl,
|
||||
sysfs_path=sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||
config=config_fixture,
|
||||
clock=clock_fixture,
|
||||
)
|
||||
|
||||
# Verify run failed
|
||||
assert result["ok"] is False
|
||||
assert "Missing required field" in result["error"]
|
||||
|
||||
# Verify nothing was written
|
||||
if os.path.exists(config_fixture["store_path"]):
|
||||
conn = sqlite3.connect(config_fixture["store_path"])
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM samples")
|
||||
count = cursor.fetchone()[0]
|
||||
assert count == 0
|
||||
conn.close()
|
||||
else:
|
||||
# Store wasn't even created - also valid
|
||||
pass
|
||||
|
||||
|
||||
# Test: Store initializes with six entities
|
||||
|
||||
def test_store_initialization(config_fixture: Dict[str, Any]):
|
||||
"""Given no existing store,
|
||||
when the collector runs,
|
||||
then the store is initialized with six entities."""
|
||||
store_path = Path(config_fixture["store_path"])
|
||||
|
||||
# Store shouldn't exist yet
|
||||
assert not store_path.exists()
|
||||
|
||||
# Initialize store
|
||||
conn = init_store(store_path)
|
||||
|
||||
# Verify all six entities exist
|
||||
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
tables = {row[0] for row in cursor.fetchall()}
|
||||
|
||||
expected_tables = {
|
||||
"samples",
|
||||
"hour_observations",
|
||||
"day_aggregates",
|
||||
"monitoring_periods",
|
||||
"controller_segments",
|
||||
"endurance_baseline",
|
||||
}
|
||||
|
||||
# sqlite_sequence is a system table created by AUTOINCREMENT
|
||||
expected_tables.add("sqlite_sequence")
|
||||
assert expected_tables == tables
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
# Test: Schema versioning with PRAGMA user_version
|
||||
|
||||
def test_schema_versioning(config_fixture: Dict[str, Any]):
|
||||
"""Given a store with unknown newer version,
|
||||
when the collector runs,
|
||||
then it refuses to proceed."""
|
||||
store_path = Path(config_fixture["store_path"])
|
||||
|
||||
# Create a store with newer version
|
||||
conn = sqlite3.connect(str(store_path))
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA user_version=999") # Unknown newer version
|
||||
conn.close()
|
||||
|
||||
# Try to initialize - should fail
|
||||
with pytest.raises(ValueError, match="newer Fenris"):
|
||||
init_store(store_path)
|
||||
|
||||
|
||||
def test_schema_version_current(config_fixture: Dict[str, Any]):
|
||||
"""Given a store with current version,
|
||||
when the collector runs,
|
||||
then it proceeds without migration."""
|
||||
from fenris.store import SCHEMA_VERSION
|
||||
|
||||
store_path = Path(config_fixture["store_path"])
|
||||
|
||||
# Initialize store
|
||||
conn1 = init_store(store_path)
|
||||
conn1.close()
|
||||
|
||||
# Open again - should succeed
|
||||
conn2 = init_store(store_path)
|
||||
|
||||
# Verify version is current
|
||||
cursor = conn2.execute("PRAGMA user_version")
|
||||
version = cursor.fetchone()[0]
|
||||
assert version == SCHEMA_VERSION
|
||||
|
||||
conn2.close()
|
||||
|
||||
|
||||
def test_schema_version_older(config_fixture: Dict[str, Any]):
|
||||
"""Given a store with older version,
|
||||
when the collector runs,
|
||||
then it applies migrations and proceeds."""
|
||||
from fenris.store import SCHEMA_VERSION
|
||||
|
||||
store_path = Path(config_fixture["store_path"])
|
||||
|
||||
# Create a store with older version
|
||||
conn = sqlite3.connect(str(store_path))
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA user_version=0") # Older version
|
||||
conn.close()
|
||||
|
||||
# Initialize store - should apply migrations
|
||||
conn = init_store(store_path)
|
||||
|
||||
# Verify version is current
|
||||
cursor = conn.execute("PRAGMA user_version")
|
||||
version = cursor.fetchone()[0]
|
||||
assert version == SCHEMA_VERSION
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
# Test: Invariant-violating run writes nothing
|
||||
|
||||
def test_invariant_violation_writes_nothing(
|
||||
smartctl_fixture: Dict[str, Any],
|
||||
sysfs_fixture_tree: Path,
|
||||
config_fixture: Dict[str, Any],
|
||||
clock_fixture,
|
||||
):
|
||||
"""Given a smartctl fixture that would violate store invariants,
|
||||
when the collector runs,
|
||||
then it writes nothing and fails visibly."""
|
||||
# Create a fixture that would cause invariant violation
|
||||
# (negative bytes_written - we'll mock this)
|
||||
bad_smartctl = smartctl_fixture.copy()
|
||||
bad_smartctl["nvme_smart_health_information_log"] = {
|
||||
**smartctl_fixture["nvme_smart_health_information_log"],
|
||||
"data_units_written": -1, # This will cause negative bytes_written
|
||||
}
|
||||
|
||||
result = run_collection(
|
||||
smartctl_data=bad_smartctl,
|
||||
sysfs_path=sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||
config=config_fixture,
|
||||
clock=clock_fixture,
|
||||
)
|
||||
|
||||
# Verify run failed
|
||||
assert result["ok"] is False
|
||||
assert "InvariantViolation" in result["error_type"]
|
||||
|
||||
# Verify nothing was written
|
||||
if os.path.exists(config_fixture["store_path"]):
|
||||
conn = sqlite3.connect(config_fixture["store_path"])
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM samples")
|
||||
count = cursor.fetchone()[0]
|
||||
assert count == 0
|
||||
conn.close()
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Test identity normalization per specification.
|
||||
|
||||
From spec §2.3:
|
||||
- strip trailing spaces and newlines
|
||||
- no case folding
|
||||
- empty-after-strip stored blank
|
||||
|
||||
Padded and unpadded renderings of the same field yield byte-identical stored values.
|
||||
"""
|
||||
import pytest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from fenris.collector import normalize_identity
|
||||
|
||||
|
||||
def test_strip_trailing_spaces():
|
||||
"""Trailing spaces are stripped."""
|
||||
identity = {"subnqn": "nqn.2014-08.org.nvmexpress:uuid:12345678 "}
|
||||
result = normalize_identity(identity)
|
||||
assert result == "nqn.2014-08.org.nvmexpress:uuid:12345678"
|
||||
|
||||
|
||||
def test_strip_trailing_newlines():
|
||||
"""Trailing newlines are stripped."""
|
||||
identity = {"subnqn": "nqn.2014-08.org.nvmexpress:uuid:12345678\n"}
|
||||
result = normalize_identity(identity)
|
||||
assert result == "nqn.2014-08.org.nvmexpress:uuid:12345678"
|
||||
|
||||
|
||||
def test_strip_trailing_spaces_and_newlines():
|
||||
"""Trailing spaces and newlines are stripped."""
|
||||
identity = {"subnqn": "nqn.2014-08.org.nvmexpress:uuid:12345678 \n\n"}
|
||||
result = normalize_identity(identity)
|
||||
assert result == "nqn.2014-08.org.nvmexpress:uuid:12345678"
|
||||
|
||||
|
||||
def test_no_case_folding():
|
||||
"""Case is preserved - no case folding."""
|
||||
identity = {"subnqn": "NQN.2014-08.ORG.NVMEXPRESS:UUID:12345678"}
|
||||
result = normalize_identity(identity)
|
||||
assert result == "NQN.2014-08.ORG.NVMEXPRESS:UUID:12345678"
|
||||
|
||||
|
||||
def test_empty_after_strip_stored_blank():
|
||||
"""Empty after strip is stored as blank string."""
|
||||
identity = {"subnqn": " \n\n "}
|
||||
result = normalize_identity(identity)
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_fallback_to_model_serial():
|
||||
"""When subnqn is empty, falls back to model|serial."""
|
||||
identity = {
|
||||
"subnqn": "",
|
||||
"mn": "Samsung SSD 970 EVO Plus 1TB",
|
||||
"sn": "S4EWNX0N123456",
|
||||
}
|
||||
result = normalize_identity(identity)
|
||||
assert result == "Samsung SSD 970 EVO Plus 1TB|S4EWNX0N123456"
|
||||
|
||||
|
||||
def test_fallback_model_serial_normalized():
|
||||
"""Model and serial are also normalized."""
|
||||
identity = {
|
||||
"subnqn": "",
|
||||
"mn": "Samsung SSD 970 EVO Plus 1TB\n",
|
||||
"sn": "S4EWNX0N123456 ",
|
||||
}
|
||||
result = normalize_identity(identity)
|
||||
assert result == "Samsung SSD 970 EVO Plus 1TB|S4EWNX0N123456"
|
||||
|
||||
|
||||
def test_all_keys_blank_returns_blank():
|
||||
"""When all keys are blank, returns blank (degraded identity)."""
|
||||
identity = {
|
||||
"subnqn": "",
|
||||
"mn": "",
|
||||
"sn": "",
|
||||
}
|
||||
result = normalize_identity(identity)
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_byte_identical_for_padded_unpadded():
|
||||
"""Padded and unpadded renderings yield byte-identical values."""
|
||||
padded = {"subnqn": "nqn.2014-08.org.nvmexpress:uuid:12345678 \n"}
|
||||
unpadded = {"subnqn": "nqn.2014-08.org.nvmexpress:uuid:12345678"}
|
||||
|
||||
result_padded = normalize_identity(padded)
|
||||
result_unpadded = normalize_identity(unpadded)
|
||||
|
||||
assert result_padded == result_unpadded
|
||||
assert result_padded == "nqn.2014-08.org.nvmexpress:uuid:12345678"
|
||||
Reference in New Issue
Block a user