Files
hms-mediaengine/packages/domain/hms_domain/identity.py
T
HMS MediaEngine Agent e4e76a7ede Phase 1 abgeschlossen: Node-Identitaet in Control Core verkabelt (§3.6, §6.3)
- NodeIdentity/NodeRole in hms_domain: persistente node_id aus
  userdata/identity, Rollen RENDER_NODE/COORDINATOR/CONTROL_DESK,
  plausible Kombinationen validiert, keine Auto-Leader-Wahl (§6.3)
- Control Core: create_app(identity) injizierbar; Dev-Modus ephemeral;
  Registry registriert eigene Node; neue Endpunkte /system/identity
  (ohne Secrets, §27.1) und /cluster/nodes (UI-Kategorien §6.3)
- 16 neue/aktualisierte Integrationstests; Gesamtsuite 199 gruen
2026-09-11 01:10:49 +02:00

98 lines
3.3 KiB
Python

"""Node-Identität und Rollen (PLAN.md §6.3, §10.1, §3.6).
Der Control Core lädt beim Start:
- persistente node_id aus userdata/identity/node_id (IP-/hostname-unabhängig)
- konfigurierbare Rollen (RENDER_NODE, COORDINATOR, CONTROL_DESK)
- Anzeigename (editierbar, nicht identitätsstiftend)
Rollen sind Pflicht für Discovery (TXT 'roles') und Cluster-Protokoll.
Es gibt in V1 keine automatische Leader-Wahl (§6.3): der Betreiber legt die
Coordinator-Rolle fest.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
from hms_domain.ids import new_node_id, persistent_node_id
class NodeRole(StrEnum):
"""Rollen gemäß §6.3. Keine Auto-Leader-Wahl: Rollen sind Konfiguration."""
RENDER_NODE = "RENDER_NODE"
COORDINATOR = "COORDINATOR"
CONTROL_DESK = "CONTROL_DESK"
_VALID_COMBOS = {
# reine Render-Nodes: decodieren und rendern lokal (§6.3)
frozenset({NodeRole.RENDER_NODE}),
# Coordinator kann auf einem Render-Node mitlaufen (§6.3)
frozenset({NodeRole.RENDER_NODE, NodeRole.COORDINATOR}),
# reiner Coordinator / Control-PC ohne Renderer (HEADLESS_CONTROL, §6.3)
frozenset({NodeRole.COORDINATOR}),
frozenset({NodeRole.CONTROL_DESK}),
frozenset({NodeRole.CONTROL_DESK, NodeRole.COORDINATOR}),
}
@dataclass(frozen=True)
class NodeIdentity:
"""Stabiles Identitätsbündel eines Servers (§3.6, §10.1).
- node_id: persistent, UUID, unabhängig von IP/Hostname
- display_name: editierbar; Umbenennung zerstört keine Automationen,
weil Parameterpfade UUID-basiert sind (§10.2)
- roles: konfigurierte Rollen; RENDER_NODE impliziert lokalen Renderer
"""
node_id: str
display_name: str
roles: frozenset[NodeRole]
def __post_init__(self) -> None:
if not self.node_id:
raise ValueError("node_id darf nicht leer sein")
if not self.roles:
raise ValueError("mindestens eine Rolle erforderlich (§6.3)")
if frozenset(self.roles) not in _VALID_COMBOS:
allowed = ", ".join(
sorted("|".join(sorted(r.value for r in c)) for c in _VALID_COMBOS)
)
raise ValueError(
f"ungültige Rollenkombination {sorted(r.value for r in self.roles)}; "
f"erlaubt: {allowed}"
)
@property
def is_coordinator(self) -> bool:
return NodeRole.COORDINATOR in self.roles
@property
def renders_locally(self) -> bool:
return NodeRole.RENDER_NODE in self.roles
@classmethod
def load_or_create(
cls,
identity_dir: Path,
display_name: str,
roles: frozenset[NodeRole],
) -> NodeIdentity:
"""Lädt die persistente node_id oder erzeugt sie genau einmal.
IP-Wechsel ändern die node_id nicht; Doppelvergabe wird über die
Datei (O_EXCL) verhindert (§6.3, hms_domain.ids).
"""
identity_file = identity_dir / "node_id"
node_id = persistent_node_id(identity_file)
return cls(node_id=node_id, display_name=display_name, roles=roles)
@classmethod
def ephemeral(cls, display_name: str, roles: frozenset[NodeRole]) -> NodeIdentity:
"""Nur für Tests: Identität ohne Persistenz (kein Showbetrieb)."""
return cls(node_id=new_node_id(), display_name=display_name, roles=roles)