Files
hms-mediaengine/packages/cluster/hms_cluster/discovery.py
T
HMS MediaEngine Agent c4574927fd Phase 1: Cluster-Fundament – Nachrichten, Registry, Paarung, Discovery
- hms_cluster: ClusterMessage (§6.5: Pflichtfelder, Sequenzen, Revisionen,
  execute_at, Trace-ID), CommandTracker (idempotent, vorwärts-only:
  accepted->armed->executed/failed)
- NodeRegistry: online/degraded/stale/offline über Schwellen, UI-Kategorien
  discovered/paired/unknown/incompatible/offline, Doppel-Node-ID blockiert,
  IP-Wechsel erhaelt node_id (§6.3, §6.5)
- Pairing: PIN (TTL 120s, Versuchslimit), Fingerprint (SHA-256 gruppiert),
  Token nur als Hash, Scopes read/control/content_sync/admin, Ablauf +
  sofortiger Widerruf (§6.3, §27.1, ADR-0010)
- Discovery-Modell: _hmsmedia._tcp.local. TXT ohne Secrets, Capability-
  Digest, persistente manuelle Fallback-Liste (ADR-0009)
- ADR-0009 (mDNS + Fallback) und ADR-0010 (Paarung) dokumentiert
- 25 Unit-Tests; Gesamtsuite 183 Tests gruen, Ruff gruen
2026-09-11 01:06:26 +02:00

132 lines
4.3 KiB
Python

"""Discovery: mDNS-Service-Modell + manuelle Fallback-Liste (PLAN.md §6.3;
ADR-0009).
- Service-Typ: _hmsmedia._tcp.local.
- TXT nur kleine, nicht vertrauliche Daten: proto, node, roles, port, caps
- manuelle Node-Liste für VLANs/geroutete Netze (§6.3)
- IP-Wechsel ändert die node_id nicht (§6.3)
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from pathlib import Path
SERVICE_TYPE = "_hmsmedia._tcp.local."
DISCOVERY_PROTOCOL_VERSION = 1
@dataclass(frozen=True)
class ServiceInfo:
"""mDNS-Ankündigung eines Nodes (TXT-Inhalt, ADR-0009)."""
node_id: str
display_name: str
port: int
roles: tuple[str, ...]
protocol_version: int = DISCOVERY_PROTOCOL_VERSION
capability_digest: str = ""
@property
def instance_name(self) -> str:
"""Eindeutiger Instanzname: bereinigter Anzeigename."""
safe = "".join(c for c in self.display_name if c.isalnum() or c in " -_")
return safe[:63] or self.node_id[:8]
def txt(self) -> dict[str, str]:
"""TXT-Record: klein, nicht vertraulich (ADR-0009, §27.1)."""
return {
"proto": str(self.protocol_version),
"node": self.node_id,
"roles": ",".join(self.roles),
"port": str(self.port),
"caps": self.capability_digest[:16],
}
@classmethod
def from_txt(cls, instance_name: str, port: int, txt: dict[str, str]) -> ServiceInfo:
"""Parst eine fremde Ankündigung; wirft bei unvollständigen Daten."""
required = ("proto", "node", "port")
missing = [k for k in required if k not in txt]
if missing:
raise ValueError(f"TXT unvollstaendig, fehlt: {missing}")
node_id = txt["node"]
if len(node_id) < 8:
raise ValueError("node-Eintrag ungueltig")
roles = tuple(r for r in txt.get("roles", "").split(",") if r)
return cls(
node_id=node_id,
display_name=instance_name,
port=int(txt["port"]) or port,
roles=roles,
protocol_version=int(txt["proto"]),
capability_digest=txt.get("caps", ""),
)
def capability_digest(capabilities: dict) -> str:
"""Kurzer, stabiler Digest über Capabilities (ADR-0009 TXT 'caps')."""
canonical = json.dumps(capabilities, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
@dataclass
class ManualNodeList:
"""Persistente manuelle Node-Liste (Fallback ohne mDNS, §6.3).
JSON-Format: Liste von {"host", "port", "node_id"}; ungeprüfte Hosts
bleiben Kategorie `unknown`, bis sich die Node legitim identifiziert.
"""
path: Path
def save(self, entries: list[dict]) -> None:
clean = [
{
"host": str(e.get("host", "")),
"port": int(e.get("port", 0)),
"node_id": str(e.get("node_id", "")),
}
for e in entries
]
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(
json.dumps(clean, indent=2, ensure_ascii=False), encoding="utf-8"
)
def load(self) -> list[dict]:
if not self.path.is_file():
return []
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return []
entries = []
for item in data if isinstance(data, list) else []:
if not isinstance(item, dict):
continue
host = str(item.get("host", ""))
if not host:
continue
try:
port = int(item.get("port", 0))
except (TypeError, ValueError):
continue
entries.append(
{"host": host, "port": port, "node_id": str(item.get("node_id", ""))}
)
return entries
def add(self, host: str, port: int, node_id: str = "") -> None:
entries = self.load()
if any(e["host"] == host and e["port"] == port for e in entries):
return
entries.append({"host": host, "port": port, "node_id": node_id})
self.save(entries)
def remove(self, host: str, port: int) -> None:
entries = [e for e in self.load() if not (e["host"] == host and e["port"] == port)]
self.save(entries)