From e4e76a7edec00be84153d65a3536c2445cfde55b Mon Sep 17 00:00:00 2001 From: HMS MediaEngine Agent Date: Fri, 11 Sep 2026 01:10:49 +0200 Subject: [PATCH] =?UTF-8?q?Phase=201=20abgeschlossen:=20Node-Identitaet=20?= =?UTF-8?q?in=20Control=20Core=20verkabelt=20(=C2=A73.6,=20=C2=A76.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- STATUS.md | 12 ++- apps/control_server/hms_control_server/app.py | 70 +++++++++++-- packages/domain/hms_domain/__init__.py | 9 +- packages/domain/hms_domain/identity.py | 97 +++++++++++++++++++ tests/integration/test_control_server.py | 61 ++++++++++-- tests/unit/test_node_identity.py | 96 ++++++++++++++++++ 6 files changed, 326 insertions(+), 19 deletions(-) create mode 100644 packages/domain/hms_domain/identity.py create mode 100644 tests/unit/test_node_identity.py diff --git a/STATUS.md b/STATUS.md index 9e23005..99fd800 100644 --- a/STATUS.md +++ b/STATUS.md @@ -59,16 +59,18 @@ Phase 1 (im Bau): - [x] Discovery-Modell: mDNS-Service _hmsmedia._tcp.local. mit TXT ohne Secrets, Capability-Digest, persistente manuelle Fallback-Liste (ADR-0009) -- [ ] Node-Identität + Rollen in die App-Verkabelung (Control Core lädt - node_id aus identity/, startet Discovery-Service auf Zielsystemen) +- [x] Node-Identität + Rollen in der App-Verkabelung: Control Core lädt + persistente node_id (Launcher/Produktion) bzw. Dev-ephemeral, + Registry registriert die eigene Node, Endpunkte /system/identity + und /cluster/nodes nach UI-Kategorien (§3.6, §6.3, §27.1) - [ ] mDNS-Echtnetz-Betrieb mit zeroconf auf Zielsystemen (Modell fertig; Multicast-Test gehört zu Gate 1, ADR-0009) ## Nächste drei Aufgaben -1. Node-Identität + Rollen in der App-Verkabelung (Control Core Startup) -2. mDNS-Echtnetz mit zeroconf auf Zielsystemen + Gate-1-LAN-Test vorbereiten -3. Phase-2-Beginn vorbereiten: Master-Canvas/Layer-Modell im Domain-Paket +1. Phase-2-Domänenmodell: Composition/Layer/MediaAsset in hms_domain (§10.1) +2. Renderer-IPC-Handshake an Control Core anbinden (Snapshot/Delta-Fluss) +3. mDNS-Echtnetz mit zeroconf auf Zielsystemen + Gate-1-LAN-Test vorbereiten ## Ausstehende Hardware-Validierung (ADR-0008 Testplan) diff --git a/apps/control_server/hms_control_server/app.py b/apps/control_server/hms_control_server/app.py index fd021d4..29b2e8b 100644 --- a/apps/control_server/hms_control_server/app.py +++ b/apps/control_server/hms_control_server/app.py @@ -1,15 +1,19 @@ -"""FastAPI-Anwendung des Control Core (Phase-0-Minimalversion). +"""FastAPI-Anwendung des Control Core. Endpunkte: - GET /api/v1/system/health +- GET /api/v1/system/identity (node_id, display_name, roles; nicht vertraulich) - GET /api/v1/system/capabilities - GET /api/v1/parameters - POST /api/v1/commands (parameter.set mit Revision-Prüfung und Idempotenz) - POST /api/v1/commands/{command_id}/release - GET /api/v1/diagnostics +- GET /api/v1/cluster/nodes - WS /ws (State-Snapshot + Updates) Commands folgen §23.2: command_id, type, expected_revision, actor, payload. +Node-Identität: persistente node_id aus userdata/identity (§3.6, §6.3); +im Dev-Modus ohne App-Root wird eine ephemeral-Identität erzeugt. """ from __future__ import annotations @@ -19,6 +23,8 @@ import uuid from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect from hms_capabilities.probe import CapabilityReport +from hms_cluster.registry import NodeRegistry +from hms_domain.identity import NodeIdentity, NodeRole from hms_parameter.engine import ( ControlSource, ParameterEngine, @@ -43,16 +49,34 @@ class ReleaseCommand(BaseModel): class _State: - def __init__(self) -> None: + def __init__(self, identity: NodeIdentity) -> None: + self.identity = identity self.engine = ParameterEngine() self.registry = IdempotencyRegistry() self.report = CapabilityReport() + self.nodes = NodeRegistry() self.subscribers: list[asyncio.Queue] = [] -def create_app() -> FastAPI: +def create_app(identity: NodeIdentity | None = None) -> FastAPI: + """Erzeugt die Control-Core-App. + + identity: produktiv vom Launcher geladene persistente Identität + (userdata/identity). Ohne Angabe gilt Dev-Modus mit ephemeral-Identität + (jede App-Instanz erhält eine eigene ID; für Showbetrieb unzulässig). + """ + if identity is None: + identity = NodeIdentity.ephemeral( + "Dev Node", frozenset({NodeRole.RENDER_NODE, NodeRole.COORDINATOR}) + ) app = FastAPI(title="HMS MediaEngine Control Core", version="0.1.0") - state = _State() + state = _State(identity) + # Eigene Node in die Registry eintragen (§6.3) + state.nodes.register( + node_id=identity.node_id, + display_name=identity.display_name, + roles=tuple(r.value for r in identity.roles), + ) def _broadcast(event: dict) -> None: for queue in list(state.subscribers): @@ -60,7 +84,23 @@ def create_app() -> FastAPI: @app.get("/api/v1/system/health") async def health() -> dict: - return {"status": "ok", "phase": 0, "revision": state.engine.revision} + return { + "status": "ok", + "phase": 1, + "node_id": state.identity.node_id, + "revision": state.engine.revision, + } + + @app.get("/api/v1/system/identity") + async def system_identity() -> dict: + """Nicht vertrauliche Selbstauskunft (§27.1: keine Tokens/Secrets).""" + return { + "node_id": state.identity.node_id, + "display_name": state.identity.display_name, + "roles": sorted(r.value for r in state.identity.roles), + "renders_locally": state.identity.renders_locally, + "is_coordinator": state.identity.is_coordinator, + } @app.get("/api/v1/system/capabilities") async def capabilities() -> dict: @@ -127,11 +167,29 @@ def create_app() -> FastAPI: @app.get("/api/v1/diagnostics") async def diagnostics() -> dict: return { - "renderer": "not_connected", # IPC-Handshake folgt in Phase 1 (ADR-0003) + "renderer": "not_connected", # IPC-Handshake folgt (ADR-0003) "artnet": "not_started", "revision": state.engine.revision, + "node_id": state.identity.node_id, } + @app.get("/api/v1/cluster/nodes") + async def cluster_nodes() -> dict: + """Node-Übersicht nach UI-Kategorien (§6.3): getrennt aufgeführt.""" + nodes = [] + for entry in state.nodes.all(): + state.nodes.evaluate_health(entry.node_id) + nodes.append( + { + "node_id": entry.node_id, + "display_name": entry.display_name, + "roles": list(entry.roles), + "health": entry.health.value, + "category": entry.category.value, + } + ) + return {"self": state.identity.node_id, "nodes": nodes} + @app.websocket("/ws") async def websocket_endpoint(ws: WebSocket) -> None: await ws.accept() diff --git a/packages/domain/hms_domain/__init__.py b/packages/domain/hms_domain/__init__.py index 74c5679..23b9f50 100644 --- a/packages/domain/hms_domain/__init__.py +++ b/packages/domain/hms_domain/__init__.py @@ -1,5 +1,12 @@ """hms_domain – plattformneutrale Domänenobjekte (PLAN.md §10).""" +from hms_domain.identity import NodeIdentity, NodeRole from hms_domain.ids import new_node_id, new_uuid, persistent_node_id -__all__ = ["new_uuid", "new_node_id", "persistent_node_id"] +__all__ = [ + "new_uuid", + "new_node_id", + "persistent_node_id", + "NodeIdentity", + "NodeRole", +] diff --git a/packages/domain/hms_domain/identity.py b/packages/domain/hms_domain/identity.py new file mode 100644 index 0000000..30ed3c3 --- /dev/null +++ b/packages/domain/hms_domain/identity.py @@ -0,0 +1,97 @@ +"""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) diff --git a/tests/integration/test_control_server.py b/tests/integration/test_control_server.py index 7b93911..db6f417 100644 --- a/tests/integration/test_control_server.py +++ b/tests/integration/test_control_server.py @@ -1,7 +1,8 @@ """Integrationstests Control Core (PLAN.md §6.1B, §23, §36 Nr. 9). FastAPI-REST + WebSocket mit derselben Parameter-Engine, die auch -Art-Net bedient (§11: eine autoritative Instanz). +Art-Net bedient (§11: eine autoritative Instanz). Node-Identität und +Cluster-Registry sind verkabelt (§6.3). """ from __future__ import annotations @@ -11,6 +12,7 @@ import uuid import pytest from fastapi.testclient import TestClient from hms_control_server import create_app +from hms_domain import NodeIdentity, NodeRole @pytest.fixture() @@ -18,6 +20,15 @@ def client() -> TestClient: return TestClient(create_app()) +@pytest.fixture() +def named_client() -> TestClient: + """App mit injizierter produktionsähnlicher Identität.""" + identity = NodeIdentity.ephemeral( + "Show Server A", frozenset({NodeRole.RENDER_NODE, NodeRole.COORDINATOR}) + ) + return TestClient(create_app(identity=identity)) + + def _payload(path: str, value: float) -> dict: return {"parameter_path": path, "value": value} @@ -27,27 +38,63 @@ def path() -> str: return f"composition/{uuid.uuid4()}/layer/{uuid.uuid4()}/opacity" -# ---------- Health/Capabilities ---------- +# ---------- Health/Capabilities/Identity ---------- def test_health(client: TestClient) -> None: r = client.get("/api/v1/system/health") assert r.status_code == 200 assert r.json()["status"] == "ok" - assert r.json()["phase"] == 0 + assert r.json()["phase"] == 1 + assert r.json()["node_id"] # Identität in Health sichtbar + + +def test_identity_reports_roles_without_secrets(named_client: TestClient) -> None: + r = named_client.get("/api/v1/system/identity") + assert r.status_code == 200 + body = r.json() + assert body["display_name"] == "Show Server A" + assert body["roles"] == ["COORDINATOR", "RENDER_NODE"] + assert body["renders_locally"] is True + assert body["is_coordinator"] is True + # keine vertraulichen Felder (§27.1) + blob = str(body).lower() + for forbidden in ("token", "secret", "password"): + assert forbidden not in blob + + +def test_two_apps_get_distinct_dev_identities() -> None: + """Ohne injizierte Identität erhält jede App-Instanz eine eigene ID + (Dev-Modus; Showbetrieb nutzt persistente Identität über den Launcher).""" + a = TestClient(create_app()).get("/api/v1/system/identity").json() + b = TestClient(create_app()).get("/api/v1/system/identity").json() + assert a["node_id"] != b["node_id"] def test_capabilities_reported_without_fake_tier(client: TestClient) -> None: r = client.get("/api/v1/system/capabilities") assert r.status_code == 200 - body = r.json() - assert body["tier"] is None # ungeprüft (CPU-only-Umgebung), kein Fake + assert r.json()["tier"] is None # ungeprüft (CPU-only), kein Fake def test_diagnostics_reports_not_connected(client: TestClient) -> None: r = client.get("/api/v1/diagnostics") assert r.status_code == 200 - assert r.json()["renderer"] == "not_connected" # IPC-Handshake Phase 1 + assert r.json()["renderer"] == "not_connected" + assert r.json()["node_id"] + + +# ---------- Cluster-Registry (§6.3) ---------- + + +def test_self_node_listed_in_cluster_nodes(named_client: TestClient) -> None: + r = named_client.get("/api/v1/cluster/nodes") + assert r.status_code == 200 + body = r.json() + self_entry = next(n for n in body["nodes"] if n["node_id"] == body["self"]) + assert self_entry["display_name"] == "Show Server A" + assert set(self_entry["roles"]) == {"RENDER_NODE", "COORDINATOR"} + assert self_entry["health"] in ("online", "offline") # ohne Heartbeat offline # ---------- Commands (§23.2) ---------- @@ -66,7 +113,7 @@ def test_duplicate_command_id_is_idempotent(client: TestClient, path: str) -> No first = client.post("/api/v1/commands", json=cmd).json() second = client.post("/api/v1/commands", json=cmd).json() assert first["status"] == "ack" - assert second.get("duplicate") is True # gleiches Ack, kein Doppel-Apply + assert second.get("duplicate") is True assert second["result"]["revision"] == first["revision"] diff --git a/tests/unit/test_node_identity.py b/tests/unit/test_node_identity.py new file mode 100644 index 0000000..cd03fdf --- /dev/null +++ b/tests/unit/test_node_identity.py @@ -0,0 +1,96 @@ +"""Unit-Tests Node-Identität und Rollen (PLAN.md §3.6, §6.3, §10.1).""" + +from __future__ import annotations + +import uuid +from pathlib import Path + +import pytest +from hms_domain import NodeIdentity, NodeRole + + +def _roles(*r: NodeRole) -> frozenset[NodeRole]: + return frozenset(r) + + +# ---------- Persistenz (§3.6, §6.3) ---------- + + +def test_load_or_create_persists_across_restart(tmp_path: Path) -> None: + identity_dir = tmp_path / "userdata" / "identity" + first = NodeIdentity.load_or_create( + identity_dir, "Show Server A", _roles(NodeRole.RENDER_NODE, NodeRole.COORDINATOR) + ) + second = NodeIdentity.load_or_create( + identity_dir, "Show Server A", _roles(NodeRole.RENDER_NODE, NodeRole.COORDINATOR) + ) + assert first.node_id == second.node_id # persistente node_id (§3.6) + uuid.UUID(first.node_id) # gültige UUID + + +def test_identity_file_location_per_spec(tmp_path: Path) -> None: + """node_id liegt unter userdata/identity/ (§9-Struktur).""" + identity_dir = tmp_path / "userdata" / "identity" + NodeIdentity.load_or_create(identity_dir, "N", _roles(NodeRole.RENDER_NODE)) + assert (identity_dir / "node_id").is_file() + + +def test_two_nodes_get_distinct_persistent_ids(tmp_path: Path) -> None: + a = NodeIdentity.load_or_create(tmp_path / "a", "Node A", _roles(NodeRole.RENDER_NODE)) + b = NodeIdentity.load_or_create(tmp_path / "b", "Node B", _roles(NodeRole.RENDER_NODE)) + assert a.node_id != b.node_id # doppelte node_id wäre Fehler (§6.3) + + +# ---------- Rollen (§6.3) ---------- + + +@pytest.mark.parametrize( + "roles", + [ + _roles(NodeRole.RENDER_NODE), + _roles(NodeRole.COORDINATOR), + _roles(NodeRole.CONTROL_DESK), + _roles(NodeRole.RENDER_NODE, NodeRole.COORDINATOR), + _roles(NodeRole.CONTROL_DESK, NodeRole.COORDINATOR), + ], +) +def test_valid_role_combinations_accepted(roles) -> None: + identity = NodeIdentity.ephemeral("Test Node", roles) + assert identity.roles == roles + + +@pytest.mark.parametrize( + "roles", + [ + frozenset(), # keine Rolle + _roles(NodeRole.RENDER_NODE, NodeRole.CONTROL_DESK), # Desk + Renderer unplausibel + ], +) +def test_invalid_role_combinations_rejected(roles) -> None: + with pytest.raises(ValueError): + NodeIdentity.ephemeral("Test Node", roles) + + +def test_role_properties() -> None: + render_only = NodeIdentity.ephemeral("R", _roles(NodeRole.RENDER_NODE)) + coord_only = NodeIdentity.ephemeral("C", _roles(NodeRole.COORDINATOR)) + both = NodeIdentity.ephemeral("B", _roles(NodeRole.RENDER_NODE, NodeRole.COORDINATOR)) + assert render_only.renders_locally and not render_only.is_coordinator + assert coord_only.is_coordinator and not coord_only.renders_locally + assert both.is_coordinator and both.renders_locally # §6.3: Coordinator auf Render-Node + + +def test_display_name_editable_without_identity_change(tmp_path: Path) -> None: + """Umbenennung ändert die node_id nicht (§10.1: Name ist kein Identitätsteil + für Parameterpfade).""" + identity_dir = tmp_path / "identity" + a = NodeIdentity.load_or_create(identity_dir, "Alter Name", _roles(NodeRole.RENDER_NODE)) + b = NodeIdentity.load_or_create(identity_dir, "Neuer Name", _roles(NodeRole.RENDER_NODE)) + assert a.node_id == b.node_id + assert a.display_name != b.display_name + + +def test_ephemeral_distinct_ids() -> None: + a = NodeIdentity.ephemeral("A", _roles(NodeRole.RENDER_NODE)) + b = NodeIdentity.ephemeral("B", _roles(NodeRole.RENDER_NODE)) + assert a.node_id != b.node_id # Tests dürfen niemals dieselbe ID teilen