"""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)