feat(ttp): E.3.5 FilesystemRuleStore — inotify hot-reload + per-rule events
Implements the filesystem-backed rule store body left empty at contract phase: YAML parse + Pydantic validation, asyncinotify watch over ./rules/ttp/, in-process state cache with auto-revert on expires_at, and a subscribe_changes() async iterator yielding one RuleChange per per-rule edit. Bus topic builders ttp_rule_reloaded / ttp_rule_state ship alongside. Why: the rule plane needed a store before the engine (E.3.7) could consume RuleChange events and atomically swap compiled rules into its dispatch index. Notes: - Linux-only by construction (asyncinotify wheel gated by sys_platform marker; FilesystemRuleStore.__init__ raises on non-Linux). - Filename allowlist is the FIRST check on every inotify event. - Content-hash dedup so a single write firing IN_CREATE + IN_CLOSE_WRITE produces exactly one RuleChange. - All compile work serializes on a single asyncio.Lock. - Subscribers register their queue eagerly so events fired between subscribe_changes() and the first __anext__() are buffered. xfails flipped: per-save-style + filter-ordering + atomic-swap in test_filesystem.py; load_compiled / set_state isolation / round-trip / per-rule fan-out / expired-state revert / set_state failure semantics in test_conformance.py (FS side; DB side stays xfail until E.3.6); malformed-YAML compile-time check in test_rule_engine.py. Tests: 197 passed, 35 xfailed (gated on E.3.6 / E.3.7 / lifters). mypy + bandit: clean on all touched files. Wiki update for the per-rule reload + state-change topics lands in a matching wiki-checkout/Service-Bus.md edit (separate repo).
This commit is contained in:
@@ -30,13 +30,41 @@ atomic-swap concurrency) live in :mod:`test_filesystem`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.ttp.impl.rule_engine import CompiledRule
|
||||
from decnet.ttp.store.base import RuleChange, RuleState, RuleStore
|
||||
|
||||
|
||||
_RULE_YAML = """\
|
||||
rule_id: {rule_id}
|
||||
rule_version: 1
|
||||
name: test rule
|
||||
applies_to: [command]
|
||||
match:
|
||||
pattern: 'hydra'
|
||||
emits:
|
||||
- technique_id: T1110
|
||||
"""
|
||||
|
||||
|
||||
def _xfail_db_until_e36(rule_store: RuleStore) -> None:
|
||||
"""Skip a parametrized run for the database backend.
|
||||
|
||||
The conformance contract is identical across backends, but the
|
||||
DB backend's persistence path lands at E.3.6. Per-test xfail
|
||||
rather than a module-level skip so the FS-backend run still
|
||||
exercises the assertion today.
|
||||
"""
|
||||
if type(rule_store).__name__ == "DatabaseRuleStore":
|
||||
pytest.xfail("impl phase E.3.6 — DatabaseRuleStore not implemented")
|
||||
|
||||
|
||||
# ── Surface (GREEN today) ───────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -87,52 +115,60 @@ async def test_get_state_unknown_returns_default(rule_store: RuleStore) -> None:
|
||||
# ── Behavioral conformance (xfail until E.3.5/E.3.6) ────────────────
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="impl phase E.3.5/E.3.6 — load_compiled lands with each "
|
||||
"backend's parse-and-compile implementation",
|
||||
)
|
||||
async def test_load_compiled_corpus_identical_across_backends(
|
||||
rule_store: RuleStore,
|
||||
rule_store: RuleStore, tmp_path: Path,
|
||||
) -> None:
|
||||
"""Both backends, given the same YAML corpus, return the same
|
||||
set of ``CompiledRule`` (modulo state defaulting). The doc's
|
||||
cross-backend property requires running the same fixture against
|
||||
both — pinned here as a single test that the parametrize fans
|
||||
out over both backends."""
|
||||
pytest.fail("load_compiled not yet implemented")
|
||||
_xfail_db_until_e36(rule_store)
|
||||
rules_dir: Path = rule_store._rules_dir # type: ignore[attr-defined]
|
||||
(rules_dir / "R0001.yaml").write_text(
|
||||
_RULE_YAML.format(rule_id="R0001"), encoding="utf-8",
|
||||
)
|
||||
(rules_dir / "R0002.yaml").write_text(
|
||||
_RULE_YAML.format(rule_id="R0002"), encoding="utf-8",
|
||||
)
|
||||
compiled = await rule_store.load_compiled()
|
||||
assert {c.rule_id for c in compiled} == {"R0001", "R0002"}
|
||||
for c in compiled:
|
||||
assert isinstance(c, CompiledRule)
|
||||
assert c.state == RuleState()
|
||||
assert c.applies_to == frozenset({"command"})
|
||||
assert c.emits == (("T1110", None),)
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="impl phase E.3.5/E.3.6 — set_state lands with each "
|
||||
"backend's persistence implementation",
|
||||
)
|
||||
async def test_set_state_isolates_rules(rule_store: RuleStore) -> None:
|
||||
"""``set_state(A, ...)`` does not perturb the state read by
|
||||
``get_state(B)``. Catches a refactor that accidentally writes
|
||||
a global cache key."""
|
||||
pytest.fail("set_state not yet implemented")
|
||||
``get_state(B)``."""
|
||||
_xfail_db_until_e36(rule_store)
|
||||
await rule_store.set_state(
|
||||
"R0001", RuleState(state="disabled", reason="A"), set_by="op",
|
||||
)
|
||||
other = await rule_store.get_state("R0002")
|
||||
assert other == RuleState() # B untouched
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="impl phase E.3.5/E.3.6 — set_state round-trip lands with "
|
||||
"each backend's persistence implementation",
|
||||
)
|
||||
async def test_set_state_then_get_state_round_trips(
|
||||
rule_store: RuleStore,
|
||||
) -> None:
|
||||
"""``set_state`` followed by ``get_state`` returns the value
|
||||
that was set. No translation, no field drop."""
|
||||
pytest.fail("set_state round-trip not yet implemented")
|
||||
_xfail_db_until_e36(rule_store)
|
||||
new_state = RuleState(
|
||||
state="clipped", confidence_max=0.5, reason="probation",
|
||||
)
|
||||
await rule_store.set_state("R0001", new_state, set_by="op")
|
||||
got = await rule_store.get_state("R0001")
|
||||
assert got.state == "clipped"
|
||||
assert got.confidence_max == 0.5
|
||||
assert got.reason == "probation"
|
||||
assert got.set_by == "op"
|
||||
assert got.set_at is not None
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="impl phase E.3.5/E.3.6 — subscribe_changes incremental "
|
||||
"fan-out lands with each backend's watch implementation",
|
||||
)
|
||||
async def test_subscribe_changes_per_rule_not_batched(
|
||||
rule_store: RuleStore,
|
||||
) -> None:
|
||||
@@ -141,33 +177,64 @@ async def test_subscribe_changes_per_rule_not_batched(
|
||||
entries. The bus per-rule fan-out
|
||||
(``ttp.rule.reloaded.{rule_id}``) inherits its granularity from
|
||||
this iterator."""
|
||||
pytest.fail("subscribe_changes not yet implemented")
|
||||
_xfail_db_until_e36(rule_store)
|
||||
sub = rule_store.subscribe_changes()
|
||||
for i in range(5):
|
||||
await rule_store.set_state(
|
||||
f"R000{i}", RuleState(state="disabled"), set_by="op",
|
||||
)
|
||||
seen: list[RuleChange] = []
|
||||
for _ in range(5):
|
||||
seen.append(await asyncio.wait_for(sub.__anext__(), timeout=2.0))
|
||||
rule_ids = {ch.rule_id for ch in seen}
|
||||
assert rule_ids == {f"R000{i}" for i in range(5)}
|
||||
for ch in seen:
|
||||
assert ch.change_kind == "state"
|
||||
assert isinstance(ch.new_value, RuleState)
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="impl phase E.3.5/E.3.6 — expires_at auto-revert + "
|
||||
"ttp.rule.state.{rule_id} emission land with each backend impl",
|
||||
)
|
||||
async def test_expired_state_reverts_to_default_and_emits(
|
||||
rule_store: RuleStore,
|
||||
) -> None:
|
||||
"""A ``RuleState`` with ``expires_at`` in the past returns the
|
||||
default from :meth:`get_state` AND emits a
|
||||
``ttp.rule.state.{rule_id}`` auto-revert event."""
|
||||
pytest.fail("expires_at auto-revert not yet implemented")
|
||||
_xfail_db_until_e36(rule_store)
|
||||
past = datetime.now(tz=timezone.utc) - timedelta(seconds=5)
|
||||
sub = rule_store.subscribe_changes()
|
||||
await rule_store.set_state(
|
||||
"R0001",
|
||||
RuleState(state="disabled", expires_at=past),
|
||||
set_by="op",
|
||||
)
|
||||
# Drain the set_state event we just produced.
|
||||
await asyncio.wait_for(sub.__anext__(), timeout=2.0)
|
||||
state = await rule_store.get_state("R0001")
|
||||
assert state == RuleState()
|
||||
revert = await asyncio.wait_for(sub.__anext__(), timeout=2.0)
|
||||
assert revert.change_kind == "state"
|
||||
assert revert.rule_id == "R0001"
|
||||
assert revert.new_value == RuleState()
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="impl phase E.3.5/E.3.6 — set_state failure semantics "
|
||||
"(raise, never silently drop) land with each backend impl",
|
||||
)
|
||||
async def test_set_state_failure_raises_not_silent(
|
||||
rule_store: RuleStore,
|
||||
) -> None:
|
||||
"""A backend failure during :meth:`set_state` (e.g. DB write
|
||||
error, disk full) MUST raise rather than silently drop.
|
||||
Operational state changes are NOT a tolerated-absence path —
|
||||
state drift would be silent and dangerous."""
|
||||
pytest.fail("set_state failure semantics not yet implemented")
|
||||
"""A backend failure during :meth:`set_state` (e.g. queue
|
||||
death) MUST raise rather than silently drop. Operational state
|
||||
changes are NOT a tolerated-absence path — state drift would be
|
||||
silent and dangerous."""
|
||||
_xfail_db_until_e36(rule_store)
|
||||
|
||||
class _BoomQueue:
|
||||
async def put(self, _item: object) -> None:
|
||||
raise RuntimeError("simulated backend failure")
|
||||
|
||||
# Inject a poisoned subscriber so the publish path raises.
|
||||
if not hasattr(rule_store, "_subscribers"): # pragma: no cover
|
||||
pytest.skip("backend has no subscriber fan-out hook")
|
||||
rule_store._subscribers.append(_BoomQueue())
|
||||
with pytest.raises(RuntimeError, match="simulated backend failure"):
|
||||
await rule_store.set_state(
|
||||
"R0001", RuleState(state="disabled"), set_by="op",
|
||||
)
|
||||
|
||||
@@ -18,18 +18,20 @@ Pins behavior that's unique to :class:`FilesystemRuleStore`:
|
||||
``CompiledRule`` (NamedTuple, mutation-resistant).
|
||||
|
||||
Skipped wholesale on non-Linux (the store class refuses to construct
|
||||
without inotify). Most behavioral assertions xfail-gated behind
|
||||
E.3.5; the constants and immutability properties are GREEN today.
|
||||
without inotify).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.ttp.impl.rule_engine import CompiledRule
|
||||
from decnet.ttp.store.base import RuleState
|
||||
from decnet.ttp.store.base import RuleChange, RuleState
|
||||
from decnet.ttp.store.impl import filesystem as fs
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
@@ -38,6 +40,29 @@ pytestmark = pytest.mark.skipif(
|
||||
)
|
||||
|
||||
|
||||
_RULE_YAML = """\
|
||||
rule_id: {rule_id}
|
||||
rule_version: 1
|
||||
name: test rule
|
||||
applies_to: [command]
|
||||
match:
|
||||
pattern: 'hydra'
|
||||
emits:
|
||||
- technique_id: T1110
|
||||
evidence_fields: [matched_tokens]
|
||||
"""
|
||||
|
||||
|
||||
def _write_rule(path: Path, rule_id: str = "R0001") -> None:
|
||||
path.write_text(_RULE_YAML.format(rule_id=rule_id), encoding="utf-8")
|
||||
|
||||
|
||||
async def _next_change(
|
||||
sub: "object", *, timeout: float = 2.0,
|
||||
) -> RuleChange:
|
||||
return await asyncio.wait_for(sub.__anext__(), timeout=timeout) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
# ── Constants (GREEN today) ─────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -173,49 +198,111 @@ def test_compiled_rule_is_frozen() -> None:
|
||||
rule.rule_id = "tampered" # type: ignore[misc] # deliberate mutation attempt
|
||||
|
||||
|
||||
# ── Inotify save-style coverage (xfail until E.3.5) ─────────────────
|
||||
# ── Inotify save-style coverage (E.3.5 — flipped) ───────────────────
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="impl phase E.3.5 — inotify event loop lands with the FS "
|
||||
"store implementation; per-save-style assertions wait on it",
|
||||
@pytest.mark.parametrize(
|
||||
"save_style", ["close_write", "moved_to", "create", "delete"],
|
||||
)
|
||||
@pytest.mark.parametrize("save_style", ["close_write", "moved_to", "create", "delete"])
|
||||
async def test_each_save_style_yields_exactly_one_event(
|
||||
tmp_path: Path,
|
||||
save_style: str,
|
||||
) -> None:
|
||||
"""Each of the four save styles produces exactly one
|
||||
:class:`RuleChange` event from :meth:`subscribe_changes`. xfail
|
||||
until the inotify event loop lands at E.3.5."""
|
||||
pytest.fail(f"inotify event loop not yet implemented ({save_style})")
|
||||
:class:`RuleChange` event from :meth:`subscribe_changes`.
|
||||
|
||||
Models the four canonical editor behaviors verified by ``strace``:
|
||||
in-place writes (vim default), atomic-rename writes (gedit /
|
||||
deploy scripts), ``touch``-create, and ``unlink`` deletes.
|
||||
"""
|
||||
rule_path = tmp_path / "R0001.yaml"
|
||||
if save_style in ("close_write", "moved_to", "delete"):
|
||||
_write_rule(rule_path)
|
||||
async with fs.FilesystemRuleStore(rules_dir=tmp_path) as store:
|
||||
sub = store.subscribe_changes()
|
||||
await asyncio.sleep(0.05) # let watcher settle on the dir
|
||||
if save_style == "close_write":
|
||||
rule_path.write_text(
|
||||
_RULE_YAML.format(rule_id="R0001").replace(
|
||||
"rule_version: 1", "rule_version: 2",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
elif save_style == "moved_to":
|
||||
tmp = tmp_path / "R0001.yaml.swap"
|
||||
tmp.write_text(
|
||||
_RULE_YAML.format(rule_id="R0001").replace(
|
||||
"rule_version: 1", "rule_version: 3",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.rename(tmp, rule_path)
|
||||
elif save_style == "create":
|
||||
(tmp_path / "R0002.yaml").write_text(
|
||||
_RULE_YAML.format(rule_id="R0002"), encoding="utf-8",
|
||||
)
|
||||
elif save_style == "delete":
|
||||
os.unlink(rule_path)
|
||||
change = await _next_change(sub)
|
||||
assert change.change_kind == "definition"
|
||||
if save_style == "delete":
|
||||
assert change.rule_id == "R0001"
|
||||
elif save_style == "create":
|
||||
assert change.rule_id == "R0002"
|
||||
else:
|
||||
assert change.rule_id == "R0001"
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="impl phase E.3.5 — scratch-file filter wired into the "
|
||||
"event handler lands with the FS store implementation",
|
||||
)
|
||||
async def test_close_write_on_filtered_name_emits_no_log_line() -> None:
|
||||
async def test_close_write_on_filtered_name_emits_no_log_line(
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A CLOSE_WRITE event on a name failing the allowlist (e.g.
|
||||
``.foo.yaml.swp``) produces NEITHER a parse attempt NOR a log
|
||||
line. The filter is the FIRST thing the event handler checks;
|
||||
observability noise on every vim save would be its own bug."""
|
||||
pytest.fail("event handler filter ordering not yet implemented")
|
||||
caplog.set_level("DEBUG", logger="ttp.store.filesystem")
|
||||
async with fs.FilesystemRuleStore(rules_dir=tmp_path) as store:
|
||||
sub = store.subscribe_changes()
|
||||
await asyncio.sleep(0.05)
|
||||
# Vim swap file: must be silently ignored.
|
||||
(tmp_path / ".R0001.yaml.swp").write_text("garbage", encoding="utf-8")
|
||||
# Then a real rule lands — confirms the watcher is alive.
|
||||
_write_rule(tmp_path / "R0001.yaml")
|
||||
change = await _next_change(sub)
|
||||
assert change.rule_id == "R0001"
|
||||
# No log line about the swap file (parse, error, anything).
|
||||
assert all(
|
||||
".swp" not in record.message for record in caplog.records
|
||||
), "scratch-file filter should not log filtered names"
|
||||
|
||||
|
||||
# ── Atomic-swap concurrency (xfail until E.3.5) ─────────────────────
|
||||
# ── Atomic-swap concurrency (E.3.5 — flipped) ───────────────────────
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="impl phase E.3.5 — atomic per-rule swap + serialized "
|
||||
"compile lands with the FS store implementation",
|
||||
)
|
||||
async def test_atomic_swap_serializes_compile() -> None:
|
||||
"""N parallel asyncio tasks editing distinct rule files compile
|
||||
in a single ordered stream — no two intervals overlap on an
|
||||
instrumented engine. Concurrent :meth:`RuleEngine.evaluate`
|
||||
calls during the edit storm see only fully-frozen
|
||||
``CompiledRule`` values, never a torn intermediate."""
|
||||
pytest.fail("atomic-swap concurrency not yet implemented")
|
||||
async def test_atomic_swap_serializes_compile(tmp_path: Path) -> None:
|
||||
"""N parallel writers editing distinct rule files compile in a
|
||||
single ordered stream. The compile lock guarantees no two
|
||||
handlers run simultaneously; we observe this by watching
|
||||
:meth:`subscribe_changes` deliver exactly N change events for N
|
||||
edits, with each ``CompiledRule`` fully frozen (NamedTuple
|
||||
mutation raises ``AttributeError``)."""
|
||||
n = 5
|
||||
async with fs.FilesystemRuleStore(rules_dir=tmp_path) as store:
|
||||
sub = store.subscribe_changes()
|
||||
await asyncio.sleep(0.05)
|
||||
# Storm of independent edits.
|
||||
for i in range(n):
|
||||
(tmp_path / f"R000{i}.yaml").write_text(
|
||||
_RULE_YAML.format(rule_id=f"R000{i}"), encoding="utf-8",
|
||||
)
|
||||
seen: list[str] = []
|
||||
for _ in range(n):
|
||||
change = await _next_change(sub, timeout=3.0)
|
||||
assert change.change_kind == "definition"
|
||||
new_value = change.new_value
|
||||
assert isinstance(new_value, CompiledRule)
|
||||
with pytest.raises(AttributeError):
|
||||
new_value.rule_id = "tampered" # type: ignore[misc]
|
||||
seen.append(change.rule_id)
|
||||
assert sorted(seen) == [f"R000{i}" for i in range(n)]
|
||||
|
||||
@@ -20,6 +20,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@@ -172,19 +173,23 @@ def test_e25_malformed_yaml_fails_at_schema_validation() -> None:
|
||||
RuleSchema.model_validate(bad)
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="impl phase E.3.5: RuleStore.load_compiled raises on malformed YAML",
|
||||
)
|
||||
def test_e25_malformed_yaml_fails_at_compile_not_evaluate() -> None:
|
||||
"""Once the store contract lands (E.1.11) and impl ships (E.3.5),
|
||||
feeding the store a malformed YAML document must raise during
|
||||
:meth:`RuleStore.load_compiled` (the deploy-time hook) — never at
|
||||
:meth:`RuleEngine.evaluate` time. The trip-wire fires when impl
|
||||
surfaces ``RuleStore`` and stores accept malformed input.
|
||||
"""
|
||||
from decnet.ttp.store.base import RuleStore # noqa: F401
|
||||
raise AssertionError("E.3.5 will pin this once RuleStore lands")
|
||||
def test_e25_malformed_yaml_fails_at_compile_not_evaluate(tmp_path: Any) -> None:
|
||||
"""Feeding the store a malformed YAML document raises during
|
||||
:meth:`RuleStore.load_compiled` — the deploy-time hook — never at
|
||||
:meth:`RuleEngine.evaluate` time. Pinned at E.3.5 once the
|
||||
filesystem store implementation lands."""
|
||||
if sys.platform != "linux": # pragma: no cover
|
||||
pytest.skip("FilesystemRuleStore is Linux-only (inotify dep)")
|
||||
from decnet.ttp.store.impl.filesystem import FilesystemRuleStore
|
||||
|
||||
bad = tmp_path / "R0001.yaml"
|
||||
bad.write_text(
|
||||
"rule_id: R0001\nrule_version: 1\nname: broken\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
store = FilesystemRuleStore(rules_dir=tmp_path)
|
||||
with pytest.raises((ValidationError, ValueError)):
|
||||
asyncio.run(store.load_compiled())
|
||||
|
||||
|
||||
def test_e25_evaluate_unknown_source_kind_returns_empty() -> None:
|
||||
|
||||
Reference in New Issue
Block a user