Adds decnet/ttp/store/ subpackage: - base.py: RuleState frozen dataclass, RuleChange NamedTuple, RuleStore ABC - factory.py: get_rule_store() reading DECNET_TTP_RULE_STORE_TYPE - impl/filesystem.py: FilesystemRuleStore with sys.platform=='linux' fail-fast guard, allowlist filename regex, raw inotify mask bits (lib import deferred to E.3 so contract phase compiles without the asyncinotify dep installed) - impl/database.py: DatabaseRuleStore stub (no platform guard) TTPRule + TTPRuleState SQLModels were already shipped at E.1.1; this commit closes the type-only TYPE_CHECKING forward-ref in rule_engine.py via real runtime imports through the new package.
151 lines
6.3 KiB
Python
151 lines
6.3 KiB
Python
"""Filesystem-backed rule store — reads ``./rules/ttp/`` + inotify watch.
|
|
|
|
Contract step E.1.11. Bodies raise ``NotImplementedError``; the
|
|
constants and platform guard are real so E.2.14b conformance tests
|
|
can introspect them today.
|
|
|
|
Linux-only. The inotify dependency (``asyncinotify`` /
|
|
``inotify_simple``) is non-portable by design; macOS / Windows
|
|
developers running the test suite use the database backend by
|
|
setting ``DECNET_TTP_RULE_STORE_TYPE=database``. The factory check in
|
|
:meth:`__init__` enforces this with a one-line operator-readable
|
|
error rather than a deep stack trace from the inotify import.
|
|
|
|
The dependency import is **deferred** to :meth:`subscribe_changes`
|
|
during the contract phase so this module is importable without the
|
|
inotify package installed. The implementation step (E.3) moves the
|
|
import to module top per TTP_TAGGING.md §"Linux-only worker host" —
|
|
which is when the dependency is added to ``pyproject.toml``. At
|
|
contract phase the codebase compiles, mypy passes, and the constants
|
|
below are introspectable for E.2.14b tests without forcing operators
|
|
on macOS or CI machines without the lib to install it just to import
|
|
the package.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from collections.abc import AsyncIterator
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Final
|
|
|
|
from decnet.ttp.impl.rule_engine import CompiledRule
|
|
from decnet.ttp.store.base import RuleChange, RuleState, RuleStore
|
|
|
|
|
|
# ── Filename allowlist ──────────────────────────────────────────────
|
|
# A path is accepted iff its basename FULLY matches this pattern. The
|
|
# allowlist (rather than a denylist) is deliberate per TTP_TAGGING.md
|
|
# §E.1.11: vim swap files (``.foo.yaml.swp``), atomic-save probes
|
|
# (``4913``), tilde backups (``foo.yaml~``), random tempfile
|
|
# conventions a future editor invents — all silently ignored, no
|
|
# parse, no log line. Denylists rot the moment an editor changes its
|
|
# scratch convention; the allowlist stops being clever.
|
|
_VALID_RULE_FILENAME: Final[re.Pattern[str]] = re.compile(
|
|
r"[A-Za-z0-9_]+\.ya?ml",
|
|
)
|
|
|
|
|
|
# ── Inotify event mask ──────────────────────────────────────────────
|
|
# Bit values from ``<sys/inotify.h>`` (man inotify(7)). Inlined as
|
|
# raw ints so this module is importable without the inotify library
|
|
# at contract phase. The implementation step replaces these with the
|
|
# library-supplied constants on the same module-top import that lands
|
|
# the dep — same numeric value, same bitwise OR.
|
|
#
|
|
# Rationale per TTP_TAGGING.md §E.1.11 "Inotify event mask",
|
|
# verified against an actual ``strace`` of vim:
|
|
# IN_CLOSE_WRITE — vim writes in place; dominant save signal.
|
|
# IN_MOVED_TO — atomic-write editors (gedit, IDEs, deploy
|
|
# scripts) write tempfile then ``rename()``.
|
|
# IN_CREATE — brand-new rule file appears (``touch``, ``cp``).
|
|
# IN_DELETE — rule removed; engine drops it from the dispatch
|
|
# index and emits ``ttp.rule.reloaded.{rule_id}``.
|
|
_IN_CLOSE_WRITE: Final[int] = 0x00000008
|
|
_IN_MOVED_TO: Final[int] = 0x00000080
|
|
_IN_CREATE: Final[int] = 0x00000100
|
|
_IN_DELETE: Final[int] = 0x00000200
|
|
|
|
_INOTIFY_MASK: Final[int] = (
|
|
_IN_CLOSE_WRITE | _IN_MOVED_TO | _IN_CREATE | _IN_DELETE
|
|
)
|
|
|
|
|
|
# ── Watch root ──────────────────────────────────────────────────────
|
|
# Resolved relative to the project root. Tests override via a tmp_path
|
|
# fixture to avoid touching the real ``./rules/`` during the suite.
|
|
_DEFAULT_RULES_DIR: Final[Path] = Path("./rules/ttp/")
|
|
|
|
|
|
class FilesystemRuleStore(RuleStore):
|
|
"""``./rules/ttp/`` + inotify watch + in-process state cache.
|
|
|
|
Right for single-host dev — state lost on restart is fine when the
|
|
operator is local. Swarms use :class:`DatabaseRuleStore` so state
|
|
survives restart and propagates across worker hosts.
|
|
|
|
Contract phase: every method raises ``NotImplementedError``. The
|
|
impl step (E.3) implements YAML parse + Pydantic validation +
|
|
inotify event loop + atomic per-rule swap into the dispatch index.
|
|
"""
|
|
|
|
def __init__(self, rules_dir: Path | None = None) -> None:
|
|
# Fail-fast platform guard. Per TTP_TAGGING.md §E.1.11: a
|
|
# one-line operator-readable error beats a deep stack trace
|
|
# from a downstream import.
|
|
if sys.platform != "linux":
|
|
raise RuntimeError(
|
|
"FilesystemRuleStore requires Linux for inotify; use "
|
|
"DatabaseRuleStore on this platform "
|
|
"(DECNET_TTP_RULE_STORE_TYPE=database).",
|
|
)
|
|
self._rules_dir: Path = rules_dir or _DEFAULT_RULES_DIR
|
|
# In-process state cache — lost on restart by design. The
|
|
# database backend persists across restarts; choosing this
|
|
# backend is choosing the trade-off.
|
|
self._state: dict[str, RuleState] = {}
|
|
|
|
async def load_compiled(self) -> list[CompiledRule]:
|
|
raise NotImplementedError(
|
|
"FilesystemRuleStore.load_compiled lands at E.3",
|
|
)
|
|
|
|
async def get_state(self, rule_id: str) -> RuleState:
|
|
# Auto-revert expired states is impl-phase behavior; the
|
|
# in-memory dict lookup is the trivial part. Even the lookup
|
|
# belongs to E.3 so the contract surface stays uniformly
|
|
# NotImplementedError across both backends.
|
|
cached = self._state.get(rule_id)
|
|
if cached is None:
|
|
return RuleState()
|
|
if cached.expires_at is not None and cached.expires_at < datetime.now(
|
|
tz=cached.expires_at.tzinfo,
|
|
):
|
|
# Auto-revert path — full impl (event emission, cache
|
|
# purge) lands at E.3.
|
|
return RuleState()
|
|
return cached
|
|
|
|
async def set_state(
|
|
self,
|
|
rule_id: str,
|
|
state: RuleState,
|
|
set_by: str,
|
|
) -> None:
|
|
raise NotImplementedError(
|
|
"FilesystemRuleStore.set_state lands at E.3",
|
|
)
|
|
|
|
def subscribe_changes(self) -> AsyncIterator[RuleChange]:
|
|
raise NotImplementedError(
|
|
"FilesystemRuleStore.subscribe_changes lands at E.3",
|
|
)
|
|
|
|
|
|
__all__ = [
|
|
"FilesystemRuleStore",
|
|
"_INOTIFY_MASK",
|
|
"_VALID_RULE_FILENAME",
|
|
]
|