135 lines
4.8 KiB
Python
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]
|