feat(ttp): E.3.6 DatabaseRuleStore — ttp_rule/ttp_rule_state + master sync
Implements the DB-backed rule store body left empty at contract phase:
load_compiled reads from ttp_rule + ttp_rule_state; get_state /
set_state hit ttp_rule_state with the same expires_at auto-revert and
bus-event semantics as the FS backend; subscribe_changes returns a
per-subscriber queue. State persists across process restarts — the
swarm property the FS backend deliberately doesn't have.
Also lands two swarm-mode helpers:
- sync_from_filesystem(fs_store) — master-side, subscribes to a
FilesystemRuleStore and projects each RuleChange onto a ttp_rule
upsert/delete.
- tail_db(poll_interval) — worker-side, watermark poll over
ttp_rule.updated_at; emits RuleChange("definition", ...) for each
row that moved.
Why: swarm mode needs rule definitions and operator state to
propagate across hosts. The filesystem backend (E.3.5) was the
single-host-dev variant; this one survives restart and serves N
workers from a shared DB.
Notes:
- DatabaseRuleStore() with no args lazy-inits an in-memory SQLite
repo so the conformance fixture works without test plumbing. In
production the worker bootstrap (E.3.14) passes an explicit repo.
- The conftest.py rule_store fixture became async (pytest_asyncio),
per-backend creates/initializes a SQLite repo for the DB run.
- Adds a `seed_rule(store, rule_id, yaml)` helper to bridge backend
semantics: drop a YAML file (FS) vs insert a ttp_rule row (DB).
Used by the parametrized load_compiled conformance test.
- Late-bound _tracer() in both backends (was module-level get_tracer
binding) so test_tracing's monkeypatch of decnet.telemetry.get_tracer
actually affects span output.
xfails flipped: tests/ttp/store/test_database.py set_state-writes-to-
ttp_rule_state + filesystem-to-DB sync; tests/ttp/store/test_conformance.py
DB-side load_compiled / set_state isolation / round-trip / per-rule
fan-out / expired-state revert / set_state failure / get_state default
(was xfail-only-on-DB); tests/ttp/test_tracing.py set_state span
hierarchy.
Tests: 208 passed, 25 xfailed (gated on E.3.7 + lifters).
mypy: clean on all touched files.
This commit is contained in:
@@ -14,29 +14,74 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
from typing import AsyncIterator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from decnet.ttp.store.base import RuleStore
|
||||
from decnet.ttp.store.impl.database import DatabaseRuleStore
|
||||
from decnet.ttp.store.impl.filesystem import FilesystemRuleStore
|
||||
from decnet.web.db.models import TTPRule
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
async def _seed_rule_filesystem(
|
||||
store: FilesystemRuleStore, rule_id: str, yaml_text: str,
|
||||
) -> None:
|
||||
rules_dir: Path = store._rules_dir
|
||||
rules_dir.mkdir(parents=True, exist_ok=True)
|
||||
(rules_dir / f"{rule_id}.yaml").write_text(yaml_text, encoding="utf-8")
|
||||
|
||||
|
||||
async def _seed_rule_database(
|
||||
store: DatabaseRuleStore, rule_id: str, yaml_text: str,
|
||||
) -> None:
|
||||
# Direct ``ttp_rule`` insert — bypass the master sync helper to
|
||||
# keep tests deterministic. Mirrors what a swarm master would have
|
||||
# written into the table.
|
||||
repo = await store._ensure_repo()
|
||||
async with repo._session() as session: # type: ignore[attr-defined]
|
||||
from datetime import datetime, timezone # noqa: PLC0415
|
||||
|
||||
session.add(TTPRule(
|
||||
rule_id=rule_id,
|
||||
rule_version=1,
|
||||
source_path=f"./rules/ttp/{rule_id}.yaml",
|
||||
yaml_content=yaml_text,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
updated_by="test",
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def seed_rule(store: RuleStore, rule_id: str, yaml_text: str) -> None:
|
||||
"""Backend-aware test helper: write a rule into the store.
|
||||
|
||||
Filesystem store: drop a YAML file under ``_rules_dir``.
|
||||
Database store: insert a ``ttp_rule`` row directly.
|
||||
"""
|
||||
if isinstance(store, FilesystemRuleStore):
|
||||
await _seed_rule_filesystem(store, rule_id, yaml_text)
|
||||
elif isinstance(store, DatabaseRuleStore):
|
||||
await _seed_rule_database(store, rule_id, yaml_text)
|
||||
else: # pragma: no cover
|
||||
raise TypeError(f"unknown rule store backend: {type(store).__name__}")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(
|
||||
params=["filesystem", "database"],
|
||||
ids=["filesystem", "database"],
|
||||
)
|
||||
def rule_store(
|
||||
async def rule_store(
|
||||
request: pytest.FixtureRequest, tmp_path: Path,
|
||||
) -> Iterator[RuleStore]:
|
||||
) -> AsyncIterator[RuleStore]:
|
||||
"""Yield a fresh :class:`RuleStore` instance per parametrization.
|
||||
|
||||
The filesystem backend is constructed against a ``tmp_path``
|
||||
rules dir so tests never touch the real ``./rules/``. The
|
||||
database backend's connection wiring lands at E.3.6; today the
|
||||
fixture just hands out the raw class instance and impl-phase
|
||||
tests are responsible for plumbing it into a session.
|
||||
database backend gets a per-test SQLite repo (initialized with
|
||||
``metadata.create_all``) so each test sees an empty
|
||||
``ttp_rule`` / ``ttp_rule_state`` pair.
|
||||
"""
|
||||
backend = request.param
|
||||
if backend == "filesystem":
|
||||
@@ -44,4 +89,20 @@ def rule_store(
|
||||
pytest.skip("FilesystemRuleStore requires Linux (inotify)")
|
||||
yield FilesystemRuleStore(rules_dir=tmp_path)
|
||||
else:
|
||||
yield DatabaseRuleStore()
|
||||
from decnet.web.db.sqlite.repository import SQLiteRepository # noqa: PLC0415
|
||||
|
||||
repo = SQLiteRepository(db_path=str(tmp_path / "ttp_store.db"))
|
||||
await repo.initialize()
|
||||
store = DatabaseRuleStore(repo=repo)
|
||||
# Mirror FS store's ``_rules_dir`` attr so cross-backend tests
|
||||
# that need to drop sample YAML on disk have somewhere to put
|
||||
# it; the DB-backend tests that need rule definitions either
|
||||
# write to ``ttp_rule`` directly or call ``upsert_rule``.
|
||||
store._rules_dir = tmp_path # type: ignore[attr-defined]
|
||||
try:
|
||||
yield store
|
||||
finally:
|
||||
try:
|
||||
await repo.engine.dispose()
|
||||
except Exception: # noqa: BLE001 — teardown best-effort
|
||||
pass
|
||||
|
||||
@@ -40,6 +40,8 @@ import pytest
|
||||
from decnet.ttp.impl.rule_engine import CompiledRule
|
||||
from decnet.ttp.store.base import RuleChange, RuleState, RuleStore
|
||||
|
||||
from .conftest import seed_rule
|
||||
|
||||
|
||||
_RULE_YAML = """\
|
||||
rule_id: {rule_id}
|
||||
@@ -53,18 +55,6 @@ emits:
|
||||
"""
|
||||
|
||||
|
||||
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) ───────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -99,14 +89,7 @@ def test_rule_change_namedtuple_shape() -> None:
|
||||
|
||||
async def test_get_state_unknown_returns_default(rule_store: RuleStore) -> None:
|
||||
"""``get_state`` for a never-set ``rule_id`` returns the default
|
||||
``RuleState`` — never raises, never returns ``None``.
|
||||
|
||||
GREEN for :class:`FilesystemRuleStore` (the impl already returns
|
||||
``RuleState()`` for an empty cache; covered in the contract
|
||||
file). xfail for :class:`DatabaseRuleStore` until E.3.6 lands.
|
||||
"""
|
||||
if type(rule_store).__name__ == "DatabaseRuleStore":
|
||||
pytest.xfail("impl phase E.3.6 — DatabaseRuleStore.get_state")
|
||||
``RuleState`` — never raises, never returns ``None``."""
|
||||
state = await rule_store.get_state("R0001_unknown_rule")
|
||||
assert state == RuleState()
|
||||
assert state.state == "enabled"
|
||||
@@ -123,14 +106,8 @@ async def test_load_compiled_corpus_identical_across_backends(
|
||||
cross-backend property requires running the same fixture against
|
||||
both — pinned here as a single test that the parametrize fans
|
||||
out over both backends."""
|
||||
_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",
|
||||
)
|
||||
await seed_rule(rule_store, "R0001", _RULE_YAML.format(rule_id="R0001"))
|
||||
await seed_rule(rule_store, "R0002", _RULE_YAML.format(rule_id="R0002"))
|
||||
compiled = await rule_store.load_compiled()
|
||||
assert {c.rule_id for c in compiled} == {"R0001", "R0002"}
|
||||
for c in compiled:
|
||||
@@ -143,7 +120,6 @@ async def test_load_compiled_corpus_identical_across_backends(
|
||||
async def test_set_state_isolates_rules(rule_store: RuleStore) -> None:
|
||||
"""``set_state(A, ...)`` does not perturb the state read by
|
||||
``get_state(B)``."""
|
||||
_xfail_db_until_e36(rule_store)
|
||||
await rule_store.set_state(
|
||||
"R0001", RuleState(state="disabled", reason="A"), set_by="op",
|
||||
)
|
||||
@@ -156,7 +132,6 @@ async def test_set_state_then_get_state_round_trips(
|
||||
) -> None:
|
||||
"""``set_state`` followed by ``get_state`` returns the value
|
||||
that was set. No translation, no field drop."""
|
||||
_xfail_db_until_e36(rule_store)
|
||||
new_state = RuleState(
|
||||
state="clipped", confidence_max=0.5, reason="probation",
|
||||
)
|
||||
@@ -177,7 +152,6 @@ 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."""
|
||||
_xfail_db_until_e36(rule_store)
|
||||
sub = rule_store.subscribe_changes()
|
||||
for i in range(5):
|
||||
await rule_store.set_state(
|
||||
@@ -199,7 +173,6 @@ async def test_expired_state_reverts_to_default_and_emits(
|
||||
"""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."""
|
||||
_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(
|
||||
@@ -224,7 +197,6 @@ async def test_set_state_failure_raises_not_silent(
|
||||
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:
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
Per ``development/TTP_TAGGING.md`` §E.2.14b: the database backend's
|
||||
tests run against BOTH SQLite and MySQL via the ``db_backends``
|
||||
fixture in :mod:`tests.web.db.conftest`. Today the database store's
|
||||
methods raise ``NotImplementedError`` so most assertions xfail.
|
||||
fixture in :mod:`tests.web.db.conftest`.
|
||||
|
||||
The cross-backend conformance assertions (load_compiled equality,
|
||||
get_state default, set_state isolation/round-trip,
|
||||
@@ -13,16 +12,26 @@ via the parametrized ``rule_store`` fixture in :mod:`conftest`.
|
||||
|
||||
This module pins behavior that's *only* meaningful for the database
|
||||
backend — specifically the propagation of state via the underlying
|
||||
``ttp_rule_state`` table, which conformance tests exercise but don't
|
||||
introspect at the SQL level.
|
||||
``ttp_rule_state`` table and the master-side filesystem→DB sync
|
||||
helper.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from decnet.ttp.impl.rule_engine import CompiledRule
|
||||
from decnet.ttp.store.base import RuleState
|
||||
from decnet.ttp.store.impl.database import DatabaseRuleStore
|
||||
from decnet.web.db.models import TTPRule, TTPRuleState
|
||||
from sqlalchemy import select as sa_select
|
||||
from sqlmodel import col
|
||||
|
||||
|
||||
def test_database_store_constructs_without_platform_guard() -> None:
|
||||
@@ -52,29 +61,148 @@ def test_async_methods_are_coroutines() -> None:
|
||||
assert inspect.iscoroutinefunction(member)
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="impl phase E.3.6 — DatabaseRuleStore needs to write "
|
||||
"into ttp_rule_state via the repository session; today the "
|
||||
"method body raises NotImplementedError",
|
||||
)
|
||||
async def test_set_state_writes_to_ttp_rule_state_table() -> None:
|
||||
@pytest_asyncio.fixture
|
||||
async def db_store(tmp_path: Path) -> Any:
|
||||
from decnet.web.db.sqlite.repository import SQLiteRepository
|
||||
|
||||
repo = SQLiteRepository(db_path=str(tmp_path / "ttp_db_store.db"))
|
||||
await repo.initialize()
|
||||
store = DatabaseRuleStore(repo=repo)
|
||||
try:
|
||||
yield store
|
||||
finally:
|
||||
try:
|
||||
await repo.engine.dispose()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
async def test_set_state_writes_to_ttp_rule_state_table(
|
||||
db_store: DatabaseRuleStore, tmp_path: Path,
|
||||
) -> None:
|
||||
"""``set_state`` writes / upserts a row in the ``ttp_rule_state``
|
||||
table. After the write, a fresh ``DatabaseRuleStore`` instance
|
||||
sees the same value via :meth:`get_state` (state survives
|
||||
process restart — that's the whole point of the database
|
||||
backend over the filesystem one)."""
|
||||
pytest.fail("DatabaseRuleStore.set_state not yet implemented")
|
||||
table. After the write, a fresh :class:`DatabaseRuleStore`
|
||||
instance pointing at the same DB sees the same value via
|
||||
:meth:`get_state` — state survives process restart, which is the
|
||||
whole point of the DB backend over the filesystem one."""
|
||||
await db_store.set_state(
|
||||
"R0001",
|
||||
RuleState(state="disabled", reason="probation"),
|
||||
set_by="anti",
|
||||
)
|
||||
repo = db_store._repo
|
||||
assert repo is not None
|
||||
async with repo._session() as session: # type: ignore[attr-defined]
|
||||
row = (
|
||||
await session.execute(
|
||||
sa_select(TTPRuleState).where(
|
||||
col(TTPRuleState.rule_id) == "R0001",
|
||||
),
|
||||
)
|
||||
).scalars().first()
|
||||
assert row is not None
|
||||
assert row.state == "disabled"
|
||||
assert row.reason == "probation"
|
||||
assert row.set_by == "anti"
|
||||
|
||||
# Fresh store instance against the same engine — state survives.
|
||||
fresh = DatabaseRuleStore(repo=repo)
|
||||
state = await fresh.get_state("R0001")
|
||||
assert state.state == "disabled"
|
||||
assert state.reason == "probation"
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="impl phase E.3.6 — master-side filesystem→DB sync of "
|
||||
"ttp_rule lands with the swarm-mode wiring",
|
||||
)
|
||||
async def test_filesystem_to_db_sync_populates_ttp_rule() -> None:
|
||||
async def test_filesystem_to_db_sync_populates_ttp_rule(
|
||||
db_store: DatabaseRuleStore, tmp_path: Path,
|
||||
) -> None:
|
||||
"""In swarm mode, the master watches ``./rules/ttp/`` and
|
||||
syncs each YAML edit into the ``ttp_rule`` table; workers
|
||||
tail the DB. This test pins the half of the contract that
|
||||
only the database backend implements."""
|
||||
pytest.fail("master-side fs→DB sync not yet implemented")
|
||||
only the database backend implements: a CompiledRule fed to
|
||||
:meth:`upsert_rule` lands as a ``ttp_rule`` row whose
|
||||
``yaml_content`` round-trips through :meth:`load_compiled`."""
|
||||
compiled = CompiledRule(
|
||||
rule_id="R0001",
|
||||
rule_version=1,
|
||||
name="brute force ssh",
|
||||
applies_to=frozenset({"command"}),
|
||||
match_spec={"pattern": "hydra"},
|
||||
emits=(("T1110", None),),
|
||||
evidence_fields=("matched_tokens",),
|
||||
state=RuleState(),
|
||||
)
|
||||
await db_store.upsert_rule(
|
||||
compiled,
|
||||
source_path="./rules/ttp/R0001.yaml",
|
||||
updated_by="filesystem",
|
||||
)
|
||||
repo = db_store._repo
|
||||
assert repo is not None
|
||||
async with repo._session() as session: # type: ignore[attr-defined]
|
||||
row = (
|
||||
await session.execute(
|
||||
sa_select(TTPRule).where(col(TTPRule.rule_id) == "R0001"),
|
||||
)
|
||||
).scalars().first()
|
||||
assert row is not None
|
||||
assert row.rule_version == 1
|
||||
assert row.updated_by == "filesystem"
|
||||
# Round-trip through load_compiled.
|
||||
loaded = await db_store.load_compiled()
|
||||
assert len(loaded) == 1
|
||||
assert loaded[0].rule_id == "R0001"
|
||||
assert loaded[0].emits == (("T1110", None),)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform != "linux",
|
||||
reason="FilesystemRuleStore is Linux-only (inotify dep)",
|
||||
)
|
||||
async def test_sync_from_filesystem_propagates_changes(
|
||||
db_store: DatabaseRuleStore, tmp_path: Path,
|
||||
) -> None:
|
||||
"""The master-side helper :meth:`sync_from_filesystem` projects
|
||||
every :class:`RuleChange` from a :class:`FilesystemRuleStore`
|
||||
onto a ``ttp_rule`` upsert. Validates the swarm-mode
|
||||
bootstrap path: master watches disk, workers tail DB."""
|
||||
import asyncio # noqa: PLC0415
|
||||
from decnet.ttp.store.impl.filesystem import FilesystemRuleStore # noqa: PLC0415
|
||||
|
||||
rules_dir = tmp_path / "rules"
|
||||
rules_dir.mkdir()
|
||||
fs_store = FilesystemRuleStore(rules_dir=rules_dir)
|
||||
|
||||
sync_task = asyncio.create_task(
|
||||
db_store.sync_from_filesystem(fs_store, updated_by="git"),
|
||||
)
|
||||
try:
|
||||
async with fs_store:
|
||||
await asyncio.sleep(0.05)
|
||||
(rules_dir / "R0042.yaml").write_text(
|
||||
"""rule_id: R0042
|
||||
rule_version: 1
|
||||
name: test
|
||||
applies_to: [command]
|
||||
match:
|
||||
pattern: 'whoami'
|
||||
emits:
|
||||
- technique_id: T1033
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
# Give the sync task a moment to project the change.
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0.05)
|
||||
loaded = await db_store.load_compiled()
|
||||
if any(c.rule_id == "R0042" for c in loaded):
|
||||
break
|
||||
else:
|
||||
pytest.fail("sync_from_filesystem did not project the edit")
|
||||
ids = {c.rule_id for c in loaded}
|
||||
assert "R0042" in ids
|
||||
finally:
|
||||
sync_task.cancel()
|
||||
try:
|
||||
await sync_task
|
||||
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
||||
pass
|
||||
|
||||
@@ -159,16 +159,38 @@ def test_rule_fire_spans_carry_rule_and_technique_attrs(
|
||||
# ── set_state span hierarchy (xfail until E.3.5/E.3.6) ──────────────
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="impl phase E.3.5/E.3.6 — set_state() span hierarchy lands "
|
||||
"with the rule-store implementations",
|
||||
)
|
||||
def test_set_state_span_hierarchy(span_exporter: tuple[InMemorySpanExporter, TracerProvider]) -> None:
|
||||
def test_set_state_span_hierarchy(
|
||||
span_exporter: tuple[InMemorySpanExporter, TracerProvider],
|
||||
) -> None:
|
||||
"""``RuleStore.set_state`` produces a ``ttp.rule.state.change``
|
||||
parent with ``ttp.store.write_state`` + ``ttp.rule.publish``
|
||||
children — operator state changes are auditable."""
|
||||
pytest.fail("set_state spans not yet emitted")
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
if sys.platform != "linux": # pragma: no cover
|
||||
pytest.skip("FilesystemRuleStore is Linux-only (inotify dep)")
|
||||
|
||||
from decnet.ttp.store.base import RuleState
|
||||
from decnet.ttp.store.impl.filesystem import FilesystemRuleStore
|
||||
|
||||
exporter, _provider = span_exporter
|
||||
|
||||
async def _run() -> None:
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
from pathlib import Path
|
||||
store = FilesystemRuleStore(rules_dir=Path(td))
|
||||
await store.set_state(
|
||||
"R0001", RuleState(state="disabled"), set_by="anti",
|
||||
)
|
||||
|
||||
asyncio.run(_run())
|
||||
names = [span.name for span in exporter.get_finished_spans()]
|
||||
assert "ttp.rule.state.change" in names
|
||||
assert "ttp.store.write_state" in names
|
||||
assert "ttp.rule.publish" in names
|
||||
|
||||
|
||||
# ── No-PII property (xfail until E.3.7+) ────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user