feat: 实现可靠事件与证据 connector (#153)
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
"""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]
|
||||
Reference in New Issue
Block a user