Files
DECNET/decnet/ttp/impl/_rule_index.py
anti 6a8af315fb fix(core): close HIGH ASVS findings V7.1.1 and correctness bugs BUG-1..6
- V7.1.1: /swarm/check no longer returns raw exception text; logs detail
  server-side, returns generic 'probe failed'.
- BUG-1: register EditAction -> SSHDriver so edit ticks no longer crash.
- BUG-2: topology reconcile matches generator-named deckies by
  expected-name membership instead of a hyphen heuristic.
- BUG-3: intel provider lookups acquire the per-provider semaphore so
  declared concurrency bounds are enforced.
- BUG-4: RuleIndex.install evicts a rule from kinds it no longer applies to.
- BUG-5: UnixSocketBus.connect() is lock-guarded with a double-check so
  concurrent first-connects open exactly one socket and reader task.
- BUG-6/V5.1.3: multi-token JSON-field search binds each token to a
  distinct parameter instead of collapsing to the last value.

Regression tests added for every fix, verified red-before/green-after.
V4.1.1c/V12.1.1 (updater master-CN gate) and V12.5.1 (tarball include-list)
confirmed already fixed in prior commits and left untouched.
2026-06-09 23:12:49 -04:00

191 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Hot-swappable rule registry shared by RuleEngine and per-source lifters.
The dispatch index originally lived inline on
:class:`~decnet.ttp.impl.rule_engine.RuleEngine`. E.3.9 adds four
per-source lifters that need the same install / evict / state-restamp
atomic-swap protocol; pulling it into one helper keeps the contract
single-sourced.
Atomicity invariant (TTP_TAGGING.md §"Atomic swap" / E.2.14b): a rule
sitting in the index must never be torn mid-evaluate. Mutations
replace dict entries with fresh lists / fresh
:class:`~decnet.ttp.impl.rule_engine.CompiledRule` tuples — never
in-place edits. Single dict assignments are GIL-atomic to readers.
"""
from __future__ import annotations
from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING
from decnet.logging import get_logger
if TYPE_CHECKING:
from decnet.ttp.impl.rule_engine import CompiledRule
from decnet.ttp.store.base import RuleChange, RuleStore
_log = get_logger("ttp.index")
class RuleIndex:
"""Owns ``rule_id -> CompiledRule`` plus a ``source_kind -> [rules]`` index.
Consumers:
* :class:`RuleEngine` — uses :meth:`by_kind` to dispatch evaluate().
* Per-source lifters (E.3.9E.3.13) — use :meth:`get` and
:meth:`values` to consume rules they own (filtered via the
``predicate`` passed to :meth:`watch`).
"""
def __init__(self) -> None:
# source_kind -> list of compiled rules that claim it.
self._by_kind: dict[str, list["CompiledRule"]] = {}
# rule_id -> compiled rule (mirror; used for state restamp).
self._by_rule: dict[str, "CompiledRule"] = {}
# ── Read API ────────────────────────────────────────────────────
def by_kind(self, source_kind: str) -> list["CompiledRule"]:
return self._by_kind.get(source_kind, [])
def get(self, rule_id: str) -> "CompiledRule | None":
return self._by_rule.get(rule_id)
def values(self) -> Iterable["CompiledRule"]:
return self._by_rule.values()
# ── Mutation API (atomic-swap) ──────────────────────────────────
def install(self, rule: "CompiledRule") -> None:
"""Atomic-swap install of one compiled rule.
Empty ``applies_to`` AND empty ``emits`` is the deletion sentinel
used by both store backends — drop the rule from the index
instead of registering a no-op entry.
"""
if not rule.applies_to and not rule.emits:
self.evict(rule.rule_id)
return
# Evict stale kind-buckets for any kinds the updated rule no longer
# claims, so re-install with a narrower applies_to doesn't leave
# ghost entries in the old buckets.
old = self._by_rule.get(rule.rule_id)
if old is not None:
stale_kinds = old.applies_to - rule.applies_to
for kind in stale_kinds:
current = self._by_kind.get(kind, [])
self._by_kind[kind] = [r for r in current if r.rule_id != rule.rule_id]
self._by_rule[rule.rule_id] = rule
for kind in rule.applies_to:
current = self._by_kind.get(kind, [])
replaced = [r for r in current if r.rule_id != rule.rule_id]
replaced.append(rule)
# Single dict assignment — GIL-atomic to readers.
self._by_kind[kind] = replaced
def evict(self, rule_id: str) -> None:
existing = self._by_rule.pop(rule_id, None)
if existing is None:
return
for kind in existing.applies_to:
current = self._by_kind.get(kind, [])
replaced = [r for r in current if r.rule_id != rule_id]
self._by_kind[kind] = replaced
def apply_change(
self, change: "RuleChange", state_cls: type
) -> None:
"""Apply one :class:`RuleChange` to the index.
``state_cls`` is :class:`RuleState`; passed in to avoid a
runtime-circular import — the store package imports from this
one transitively.
"""
from decnet.ttp.impl.rule_engine import CompiledRule # noqa: PLC0415
if change.change_kind == "definition":
value = change.new_value
if isinstance(value, CompiledRule):
self.install(value)
return
# state change
existing = self._by_rule.get(change.rule_id)
if existing is None or not isinstance(change.new_value, state_cls):
return
new_state = change.new_value
# NamedTuple._replace returns a fresh frozen tuple — single
# dict assignment swaps it in atomically.
restamped = existing._replace(state=new_state) # type: ignore[arg-type]
self._by_rule[change.rule_id] = restamped
for kind in restamped.applies_to:
current = self._by_kind.get(kind, [])
replaced = [r for r in current if r.rule_id != change.rule_id]
replaced.append(restamped)
self._by_kind[kind] = replaced
# ── Lifecycle ───────────────────────────────────────────────────
async def hydrate_from(
self,
store: "RuleStore",
predicate: Callable[["CompiledRule"], bool] | None = None,
) -> None:
"""Load every compiled rule from *store* and install matching ones.
``predicate`` filters; engine omits it (installs everything),
lifters pass a ``match.kind`` prefix check.
"""
compiled = await store.load_compiled()
for rule in compiled:
if predicate is not None and not predicate(rule):
continue
self.install(rule)
async def watch(
self,
store: "RuleStore",
predicate: Callable[["CompiledRule"], bool] | None = None,
) -> None:
"""Hydrate once + drain ``subscribe_changes`` forever.
Cancellation-safe: an :class:`asyncio.CancelledError` from the
outer task propagates cleanly. Per-change application errors
log and continue — one bad rule edit must not stall the stream.
"""
from decnet.ttp.store.base import RuleState # noqa: PLC0415
await self.hydrate_from(store, predicate=predicate)
async for change in store.subscribe_changes():
if predicate is not None:
# For state changes the value is a RuleState (no
# match_spec to inspect); always apply when the rule
# is already in the index, otherwise skip.
if change.change_kind == "state":
if change.rule_id not in self._by_rule:
continue
else:
value = change.new_value
# Definition changes carry a CompiledRule; skip
# ones the predicate doesn't claim. A previously-
# owned rule whose YAML moved out of our ownership
# gets evicted explicitly.
from decnet.ttp.impl.rule_engine import ( # noqa: PLC0415
CompiledRule,
)
if isinstance(value, CompiledRule) and not predicate(value):
if change.rule_id in self._by_rule:
self.evict(change.rule_id)
continue
try:
self.apply_change(change, RuleState)
except Exception: # noqa: BLE001
_log.exception(
"ttp.index: rule change apply failed rule_id=%s",
change.rule_id,
)
__all__ = ["RuleIndex"]