Files
Fenris/tests/test_segmentation.py

466 lines
15 KiB
Python

"""Controller segmentation tests.
Covers acceptance criteria:
- ID-1: Identity key ladder, FR metadata only, independent axes
- ID-2: Frozen metadata snapshot at segment open
- ID-4: identity_degraded set exactly when key is blank
- AC-4: vid/ssvid from PCI node, stored null otherwise
- PR-16: Blank-key semantics (to/from blank quarantines, equal blanks continue)
"""
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict
import pytest
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import init_store
from fenris.collector import normalize_identity, compute_identity_degraded, acquire_from_sysfs
from fenris.segment import find_current_segment, should_open_new_segment, open_segment
# Fixtures
@pytest.fixture
def store(tmp_path: Path) -> sqlite3.Connection:
conn = init_store(tmp_path / "observations.db")
yield conn
conn.close()
@pytest.fixture
def clock():
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()
@pytest.fixture
def identity_nqn() -> Dict[str, Any]:
return {
"subnqn": "nqn.2014-08.org.nvmexpress:uuid:12345678-1234-1234-1234-123456789abc",
"mn": "Samsung SSD 970 EVO Plus 1TB",
"sn": "S4EWNX0N123456",
"fr": "2B2QEXM7",
"vid": "0x144d",
"ssvid": "0x144d",
"transport": "pcie",
}
@pytest.fixture
def identity_model_serial() -> Dict[str, Any]:
return {
"subnqn": "",
"mn": "Samsung SSD 970 EVO Plus 1TB",
"sn": "S4EWNX0N123456",
"fr": "2B2QEXM7",
"vid": "0x144d",
"ssvid": "0x144d",
"transport": "pcie",
}
@pytest.fixture
def identity_degraded() -> Dict[str, Any]:
return {
"subnqn": "",
"mn": "",
"sn": "",
"fr": "2B2QEXM7",
"vid": "0x144d",
"ssvid": "0x144d",
"transport": "pcie",
}
# ID-1: Identity key ladder
class TestIdentityKeyLadder:
def test_subnqn_primary(self, identity_nqn):
key = normalize_identity(identity_nqn)
assert key == "nqn.2014-08.org.nvmexpress:uuid:12345678-1234-1234-1234-123456789abc"
def test_fallback_to_model_serial(self, identity_model_serial):
key = normalize_identity(identity_model_serial)
assert key == "Samsung SSD 970 EVO Plus 1TB|S4EWNX0N123456"
def test_all_blank_degraded(self, identity_degraded):
key = normalize_identity(identity_degraded)
assert key == ""
def test_firmware_is_metadata_only(self, identity_nqn):
key = normalize_identity(identity_nqn)
assert "2B2QEXM7" not in key
# ID-2: Frozen metadata snapshot
class TestFrozenMetadataSnapshot:
def test_segment_stores_all_metadata_fields(self, store, clock, identity_nqn):
key = normalize_identity(identity_nqn)
degraded = compute_identity_degraded(identity_nqn)
segment = open_segment(store, clock.now, identity_nqn, key, degraded)
assert segment["identity_key"] == key
assert segment["identity_degraded"] is False
assert segment["subnqn"] == identity_nqn["subnqn"]
assert segment["sn"] == identity_nqn["sn"]
assert segment["mn"] == identity_nqn["mn"]
assert segment["fr"] == identity_nqn["fr"]
assert segment["vid"] == identity_nqn["vid"]
assert segment["ssvid"] == identity_nqn["ssvid"]
assert segment["transport"] == identity_nqn["transport"]
def test_metadata_nullable(self, store, clock):
sparse_identity = {
"subnqn": "",
"mn": "Legacy Model",
"sn": "LEGACY123",
"fr": "1.0",
"vid": None,
"ssvid": None,
"transport": None,
}
key = normalize_identity(sparse_identity)
degraded = compute_identity_degraded(sparse_identity)
segment = open_segment(store, clock.now, sparse_identity, key, degraded)
assert segment["vid"] is None
assert segment["ssvid"] is None
assert segment["transport"] is None
def test_cntlid_excluded(self, store, clock, identity_nqn):
key = normalize_identity(identity_nqn)
degraded = compute_identity_degraded(identity_nqn)
segment = open_segment(store, clock.now, identity_nqn, key, degraded)
assert "cntlid" not in segment
def test_metadata_immutable_after_open(self, store, clock, identity_nqn):
key = normalize_identity(identity_nqn)
degraded = compute_identity_degraded(identity_nqn)
open_segment(store, clock.now, identity_nqn, key, degraded)
found = find_current_segment(store)
assert found["subnqn"] == identity_nqn["subnqn"]
assert found["vid"] == identity_nqn["vid"]
assert found["ssvid"] == identity_nqn["ssvid"]
# ID-4: identity_degraded
class TestIdentityDegraded:
def test_degraded_when_blank_key(self, store, clock, identity_degraded):
key = normalize_identity(identity_degraded)
degraded = compute_identity_degraded(identity_degraded)
assert key == ""
assert degraded is True
segment = open_segment(store, clock.now, identity_degraded, key, degraded)
assert segment["identity_degraded"] is True
def test_not_degraded_with_subnqn(self, store, clock, identity_nqn):
key = normalize_identity(identity_nqn)
degraded = compute_identity_degraded(identity_nqn)
assert key != ""
assert degraded is False
segment = open_segment(store, clock.now, identity_nqn, key, degraded)
assert segment["identity_degraded"] is False
def test_not_degraded_with_model_serial(self, store, clock, identity_model_serial):
key = normalize_identity(identity_model_serial)
degraded = compute_identity_degraded(identity_model_serial)
assert key != ""
assert degraded is False
segment = open_segment(store, clock.now, identity_model_serial, key, degraded)
assert segment["identity_degraded"] is False
# AC-4: vid/ssvid from PCI node
class TestVidSsvidAcquisition:
def test_vid_ssvid_from_pci_node(self, tmp_path: Path):
ctrl_dir = tmp_path / "nvme0"
ctrl_dir.mkdir()
(ctrl_dir / "subsysnqn").write_text("nqn.test\n")
(ctrl_dir / "model").write_text("Test Model\n")
(ctrl_dir / "serial").write_text("TEST123\n")
(ctrl_dir / "firmware_rev").write_text("1.0\n")
(ctrl_dir / "vendor").write_text("0x144d\n")
(ctrl_dir / "subsystem_vendor").write_text("0x144d\n")
identity = acquire_from_sysfs(ctrl_dir)
assert identity["vid"] == "0x144d"
assert identity["ssvid"] == "0x144d"
def test_vid_ssvid_null_when_absent(self, tmp_path: Path):
ctrl_dir = tmp_path / "nvme0"
ctrl_dir.mkdir()
(ctrl_dir / "subsysnqn").write_text("nqn.test\n")
(ctrl_dir / "model").write_text("Test Model\n")
(ctrl_dir / "serial").write_text("TEST123\n")
(ctrl_dir / "firmware_rev").write_text("1.0\n")
identity = acquire_from_sysfs(ctrl_dir)
assert identity["vid"] is None
assert identity["ssvid"] is None
def test_vid_ssvid_metadata_only(self, store, clock, identity_nqn):
identity_a = {**identity_nqn, "vid": "0x144d", "ssvid": "0x144d"}
identity_b = {**identity_nqn, "vid": "0xFFFF", "ssvid": "0xFFFF"}
key_a = normalize_identity(identity_a)
key_b = normalize_identity(identity_b)
assert key_a == key_b
# PR-16: Blank-key semantics
class TestBlankKeySemantics:
def test_to_blank_quarantines(self, store, clock):
healthy = {
"subnqn": "nqn.healthy",
"mn": "Model A", "sn": "SN1", "fr": "1.0",
"vid": "0x1", "ssvid": "0x1", "transport": "pcie",
}
key_healthy = normalize_identity(healthy)
degraded_healthy = compute_identity_degraded(healthy)
open_segment(store, clock.now, healthy, key_healthy, degraded_healthy)
current = find_current_segment(store)
blank = {
"subnqn": "", "mn": "", "sn": "",
"fr": "1.0", "vid": "0x1", "ssvid": "0x1", "transport": "pcie",
}
key_blank = normalize_identity(blank)
should_open, reason = should_open_new_segment(
current, key_blank, 1000, store
)
assert should_open is True
assert reason == "identity_change"
def test_from_blank_quarantines(self, store, clock):
blank = {
"subnqn": "", "mn": "", "sn": "",
"fr": "1.0", "vid": "0x1", "ssvid": "0x1", "transport": "pcie",
}
key_blank = normalize_identity(blank)
degraded_blank = compute_identity_degraded(blank)
open_segment(store, clock.now, blank, key_blank, degraded_blank)
current = find_current_segment(store)
healthy = {
"subnqn": "nqn.healthy",
"mn": "Model A", "sn": "SN1", "fr": "1.0",
"vid": "0x1", "ssvid": "0x1", "transport": "pcie",
}
key_healthy = normalize_identity(healthy)
should_open, reason = should_open_new_segment(
current, key_healthy, 1000, store
)
assert should_open is True
assert reason == "identity_change"
def test_equal_blanks_continue_by_duw(self, store, clock):
blank = {
"subnqn": "", "mn": "", "sn": "",
"fr": "1.0", "vid": "0x1", "ssvid": "0x1", "transport": "pcie",
}
key_blank = normalize_identity(blank)
degraded_blank = compute_identity_degraded(blank)
open_segment(store, clock.now, blank, key_blank, degraded_blank)
current = find_current_segment(store)
store.execute(
"INSERT INTO samples (ts, device, bytes_written) VALUES (?, ?, ?)",
("2026-09-01T12:00:00Z", "/dev/nvme0", 1000)
)
store.commit()
should_open, reason = should_open_new_segment(
current, key_blank, 2000, store
)
assert should_open is False
assert reason is None
def test_equal_blanks_duw_decrease_opens(self, store, clock):
blank = {
"subnqn": "", "mn": "", "sn": "",
"fr": "1.0", "vid": "0x1", "ssvid": "0x1", "transport": "pcie",
}
key_blank = normalize_identity(blank)
degraded_blank = compute_identity_degraded(blank)
open_segment(store, clock.now, blank, key_blank, degraded_blank)
current = find_current_segment(store)
store.execute(
"INSERT INTO samples (ts, device, bytes_written) VALUES (?, ?, ?)",
("2026-09-01T12:00:00Z", "/dev/nvme0", 2000)
)
store.commit()
should_open, reason = should_open_new_segment(
current, key_blank, 1000, store
)
assert should_open is True
assert reason == "duw_decrease"
# Independent segmentation axes
class TestIndependentAxes:
def test_identity_change_with_duw_increase(self, store, clock):
identity_a = {
"subnqn": "nqn.drive-a",
"mn": "Model A", "sn": "SN1", "fr": "1.0",
"vid": "0x1", "ssvid": "0x1", "transport": "pcie",
}
key_a = normalize_identity(identity_a)
degraded_a = compute_identity_degraded(identity_a)
open_segment(store, clock.now, identity_a, key_a, degraded_a)
current = find_current_segment(store)
store.execute(
"INSERT INTO samples (ts, device, bytes_written) VALUES (?, ?, ?)",
("2026-09-01T12:00:00Z", "/dev/nvme0", 2000)
)
store.commit()
identity_b = {
"subnqn": "nqn.drive-b",
"mn": "Model B", "sn": "SN2", "fr": "2.0",
"vid": "0x2", "ssvid": "0x2", "transport": "pcie",
}
key_b = normalize_identity(identity_b)
should_open, reason = should_open_new_segment(
current, key_b, 3000, store
)
assert should_open is True
assert reason == "identity_change"
def test_duw_decrease_same_identity(self, store, clock):
identity = {
"subnqn": "nqn.drive-a",
"mn": "Model A", "sn": "SN1", "fr": "1.0",
"vid": "0x1", "ssvid": "0x1", "transport": "pcie",
}
key = normalize_identity(identity)
degraded = compute_identity_degraded(identity)
open_segment(store, clock.now, identity, key, degraded)
current = find_current_segment(store)
store.execute(
"INSERT INTO samples (ts, device, bytes_written) VALUES (?, ?, ?)",
("2026-09-01T12:00:00Z", "/dev/nvme0", 5000)
)
store.commit()
should_open, reason = should_open_new_segment(
current, key, 3000, store
)
assert should_open is True
assert reason == "duw_decrease"
# Segment lifecycle
class TestSegmentLifecycle:
def test_first_segment_always_opens(self, store):
key = "nqn.test"
should_open, reason = should_open_new_segment(
None, key, 1000, store
)
assert should_open is True
assert reason == "first_segment"
def test_same_identity_duw_non_decreasing_continues(self, store, clock):
identity = {
"subnqn": "nqn.drive",
"mn": "Model", "sn": "SN1", "fr": "1.0",
"vid": "0x1", "ssvid": "0x1", "transport": "pcie",
}
key = normalize_identity(identity)
degraded = compute_identity_degraded(identity)
open_segment(store, clock.now, identity, key, degraded)
current = find_current_segment(store)
store.execute(
"INSERT INTO samples (ts, device, bytes_written) VALUES (?, ?, ?)",
("2026-09-01T12:00:00Z", "/dev/nvme0", 1000)
)
store.commit()
should_open, reason = should_open_new_segment(
current, key, 1500, store
)
assert should_open is False
assert reason is None
def test_multiple_segments(self, store, clock):
identity_a = {
"subnqn": "nqn.drive-a",
"mn": "Model A", "sn": "SN1", "fr": "1.0",
"vid": "0x1", "ssvid": "0x1", "transport": "pcie",
}
key_a = normalize_identity(identity_a)
degraded_a = compute_identity_degraded(identity_a)
open_segment(store, clock.now, identity_a, key_a, degraded_a)
identity_b = {
"subnqn": "nqn.drive-b",
"mn": "Model B", "sn": "SN2", "fr": "2.0",
"vid": "0x2", "ssvid": "0x2", "transport": "pcie",
}
key_b = normalize_identity(identity_b)
degraded_b = compute_identity_degraded(identity_b)
open_segment(store, clock.now, identity_b, key_b, degraded_b)
cursor = store.execute("SELECT COUNT(*) FROM controller_segments")
count = cursor.fetchone()[0]
assert count == 2
current = find_current_segment(store)
assert current["identity_key"] == key_b