Files
yovision/Brain/src/yovision_brain/integration/event_export/runtime.py
T

135 lines
4.8 KiB
Python

"""Closed runtime configuration and event sink for Brain-to-Sense export."""
from __future__ import annotations
from pathlib import Path
from typing import Mapping
from yovision_brain.events import InternalEventCandidate
from yovision_brain.integration.machine_identity import (
Signer,
TransportPolicy,
load_private_key,
)
from .client import HTTPSMachineIdentitySender
from .mapper import canonical_json_bytes, export_event
class EventExportSink:
def __init__(
self,
sender: HTTPSMachineIdentitySender,
*,
producer_id: str,
site_ref: str,
severity: str,
region_refs: Mapping[str, str],
crossing_directions: Mapping[str, str],
) -> None:
self._sender = sender
self._producer_id = producer_id
self._site_ref = site_ref
self._severity = severity
self._region_refs = dict(region_refs)
self._crossing_directions = dict(crossing_directions)
def write(self, candidate: InternalEventCandidate) -> None:
event = export_event(
candidate,
producer_id=self._producer_id,
site_ref=self._site_ref,
severity=self._severity,
region_ref=self._region_refs.get(candidate.rule_id),
crossing_direction=self._crossing_directions.get(candidate.rule_id),
)
# Mapping and serialization happen before minting, so the exact bytes are
# bound to the machine token and remain unchanged for this delivery.
self._sender.send(canonical_json_bytes(event))
def build_event_export_sink(raw: object, *, base_dir: Path) -> EventExportSink | None:
if raw is None:
return None
config = _object("event_export", raw)
_closed(
"event_export",
config,
{
"enabled", "endpoint", "producer_id", "site_ref", "severity",
"region_refs", "crossing_directions", "machine_identity", "transport",
},
)
enabled = config.get("enabled", False)
if not isinstance(enabled, bool):
raise ValueError("event_export.enabled must be a boolean")
if not enabled:
if set(config) != {"enabled"}:
raise ValueError("disabled event_export may only contain enabled")
return None
identity = _object("event_export.machine_identity", config.get("machine_identity"))
_closed(
"event_export.machine_identity",
identity,
{"principal", "key_id", "private_key_path"},
)
transport_raw = _object("event_export.transport", config.get("transport"))
_closed(
"event_export.transport",
transport_raw,
{
"tls_min_version", "verify_certificate", "verify_hostname",
"connect_timeout_ms", "response_header_timeout_ms", "request_timeout_ms",
"max_request_bytes",
},
)
policy = TransportPolicy(**transport_raw) # type: ignore[arg-type]
policy.validate()
key_path = _string("private_key_path", identity.get("private_key_path"))
resolved_key_path = Path(key_path)
if not resolved_key_path.is_absolute():
resolved_key_path = base_dir / resolved_key_path
signer = Signer(
_string("principal", identity.get("principal")),
_string("key_id", identity.get("key_id")),
load_private_key(resolved_key_path),
)
return EventExportSink(
HTTPSMachineIdentitySender(
_string("endpoint", config.get("endpoint")), signer, policy
),
producer_id=_string("producer_id", config.get("producer_id")),
site_ref=_string("site_ref", config.get("site_ref")),
severity=_string("severity", config.get("severity")),
region_refs=_string_map("region_refs", config.get("region_refs", {})),
crossing_directions=_string_map(
"crossing_directions", config.get("crossing_directions", {})
),
)
def _object(name: str, value: object) -> Mapping[str, object]:
if not isinstance(value, Mapping) or any(not isinstance(key, str) for key in value):
raise ValueError(f"{name} must be an object")
return value
def _closed(name: str, value: Mapping[str, object], allowed: set[str]) -> None:
unknown = set(value) - allowed
if unknown:
raise ValueError(f"{name} contains unsupported fields")
def _string(name: str, value: object) -> str:
if not isinstance(value, str) or not value.strip():
raise ValueError(f"event_export.{name} must be a non-empty string")
return value
def _string_map(name: str, value: object) -> Mapping[str, str]:
mapping = _object(f"event_export.{name}", value)
if any(not isinstance(item, str) or not item for item in mapping.values()):
raise ValueError(f"event_export.{name} values must be non-empty strings")
return mapping # type: ignore[return-value]