From bd732795ff94159fbb9836175f3245059d9f0745 Mon Sep 17 00:00:00 2001 From: Andre Merzky Date: Sat, 15 Aug 2026 19:52:48 +0200 Subject: [PATCH 01/11] A second data plane: the ORBIT-backed pubsub backend The DT streams carry cloudpickled payloads over ZMQ ports that nobody authenticates: reaching them is code execution in every subscriber (plan risk R7). This puts the same payloads inside ORBIT's token-authenticated star instead, behind the backend seam M0 built, so nothing above it notices. `OrbitPubSubBackend` publishes with `EndpointRuntime.send_notification` and subscribes with `register_callback`, one registration per DT topic under a single `dt_stream` plugin namespace -- ORBIT matches topics by exact equality, which is what DT topics already are. It owns one participant connection per twin (rhapsody's `OrbitExecutionBackend` pattern, loopback wrinkle and all), or rides an injected one. Frames cross from the runtime's callback thread to the host loop through a bounded drop-oldest inbox -- the broker's own discipline, and the conflation semantics the data plane is specified with. Oversized payloads raise at publish, because ORBIT would drop them with nothing but a log line. The two backends had grown the same receive loop, subscriber registry and closed/running state, so those move up into `PubSubBackend`. `DT_STREAM_BACKEND` selects the transport at deployment time. Under 'orbit' the plugin's embedded ZMQ broker is never started at all -- the `connect_stream` seam is the only caller of `stream_addresses`, so that is structural rather than remembered. Co-Authored-By: Claude Fable 5 --- src/digitaltwin/__init__.py | 3 +- src/digitaltwin/config.py | 57 ++++- src/digitaltwin/service/plugin.py | 79 +++++-- src/digitaltwin/service/session.py | 9 +- src/digitaltwin/streaming.py | 168 ++++++++++---- src/digitaltwin/streaming_orbit.py | 337 +++++++++++++++++++++++++++++ test/unit/test_service.py | 3 +- 7 files changed, 580 insertions(+), 76 deletions(-) create mode 100644 src/digitaltwin/streaming_orbit.py diff --git a/src/digitaltwin/__init__.py b/src/digitaltwin/__init__.py index d882b6c..dc541d0 100644 --- a/src/digitaltwin/__init__.py +++ b/src/digitaltwin/__init__.py @@ -13,7 +13,7 @@ WindowDataType, WindowedTypeData, ) -from .config import stream_addresses +from .config import stream_addresses, stream_backend from .runtime import DTRuntime, RuntimeAPI, RuntimeState from .streaming import ( CODEC_CLOUDPICKLE, @@ -56,4 +56,5 @@ "ZMQ_PS_Client", "connect_stream_client", "stream_addresses", + "stream_backend", ] diff --git a/src/digitaltwin/config.py b/src/digitaltwin/config.py index b43292a..65a674c 100644 --- a/src/digitaltwin/config.py +++ b/src/digitaltwin/config.py @@ -1,13 +1,22 @@ """Deployment configuration for the digital twin framework. -The single place where stream endpoint addresses are decided. No other -module (and no demo) may contain a hardcoded transport address. - -Binding policy: the framework binds to loopback unless the deployment -explicitly configures something else. The pubsub data plane carries -cloudpickled payloads, so anyone who can reach the broker ports can -execute code in every subscriber -- non-loopback binds require an -explicit configuration and a firewalled/private network. +The single place where the stream transport is decided: which backend +carries the data plane, and -- for the ZMQ one -- at which addresses. No +other module (and no demo) may contain a hardcoded transport address. + +Backend choice (`DT_STREAM_BACKEND`) is a deployment-time decision at the +same altitude as the compute backend; nothing above `PubSubBackend` +depends on which one is in use. + +- `zmq` (default): the framework's own XSUB/XPUB broker. Local and + two-terminal use. Its payloads are cloudpickled and its ports carry no + authentication, so anyone who can reach them can execute code in every + subscriber (risk R7). Hence the binding policy: loopback unless the + deployment explicitly configures something else, and a non-loopback + bind requires a firewalled/private network. +- `orbit`: ORBIT eventing. The same cloudpickled payloads, but inside + the token-authenticated WS star -- no DT-owned ports at all. This is + what closes R7 for a deployment, and it is required before production. """ import os @@ -27,6 +36,38 @@ ENV_PUB_ADDR = "DT_STREAM_PUB_ADDR" ENV_SUB_ADDR = "DT_STREAM_SUB_ADDR" +# which transport carries the data plane -- see the module docstring +BACKEND_ZMQ = "zmq" +BACKEND_ORBIT = "orbit" +STREAM_BACKENDS = (BACKEND_ZMQ, BACKEND_ORBIT) + +# zmq: the local/two-terminal default. A deployment that wants R7 closed +# selects 'orbit' -- deliberately, not by accident of the environment. +DEFAULT_STREAM_BACKEND = BACKEND_ZMQ + +ENV_STREAM_BACKEND = "DT_STREAM_BACKEND" + + +def stream_backend(name: str | None = None) -> str: + """Resolve which pubsub backend carries the data plane. + + Precedence: explicit argument, then `DT_STREAM_BACKEND`, then `zmq`. + An unknown name is an error rather than a silent fallback -- a typo + must not quietly reopen the ZMQ ports of a deployment that asked for + the token-authenticated one. + """ + + chosen = (name or os.environ.get(ENV_STREAM_BACKEND) + or DEFAULT_STREAM_BACKEND).strip().lower() + + if chosen not in STREAM_BACKENDS: + raise ValueError( + f"unknown stream backend {chosen!r};" + f" expected one of {', '.join(STREAM_BACKENDS)}" + ) + + return chosen + def stream_addresses( pub_addr: str | None = None, sub_addr: str | None = None diff --git a/src/digitaltwin/service/plugin.py b/src/digitaltwin/service/plugin.py index 60146f0..b94d986 100644 --- a/src/digitaltwin/service/plugin.py +++ b/src/digitaltwin/service/plugin.py @@ -11,11 +11,17 @@ - the wire: one graph verb per `twin_call`, cloudpickle-base64 payloads with a version stamp, and `twin_list` as the only observation path. -**Binding policy (risk R7)**: the stream broker binds loopback by -default. Its payloads are cloudpickled, so anyone who can reach its -ports executes code in every subscriber. A non-loopback bind needs an -explicit `DT_STREAM_PUB_ADDR` / `DT_STREAM_SUB_ADDR` configuration *and* -a firewalled/private network. +**Data plane (risk R7)**: `DT_STREAM_BACKEND` picks the transport. + +- `orbit` puts the twins' streams inside the same token-authenticated + WebSocket star as the control plane, and the embedded ZMQ broker is + then never started -- no DT-owned ports exist at all. This is what + closes R7, and it is what a production deployment selects. +- `zmq` (the default) runs the embedded stream broker, and *binds + loopback*. Its payloads are cloudpickled, so anyone who can reach its + ports executes code in every subscriber. A non-loopback bind needs an + explicit `DT_STREAM_PUB_ADDR` / `DT_STREAM_SUB_ADDR` configuration + *and* a firewalled/private network. """ import asyncio @@ -30,8 +36,13 @@ from radical.orbit.plugin_base import Plugin from starlette.requests import Request -from ..config import embedded_stream_addresses -from ..streaming import ZMQ_BrokerProcess +from ..config import BACKEND_ORBIT, embedded_stream_addresses, stream_backend +from ..streaming import ( + CLIENT_CONNECT_TIMEOUT, + PubSubClient, + ZMQ_BrokerProcess, + connect_stream_client, +) from .client import DTClient from .session import VERBS, DTSession @@ -82,8 +93,13 @@ def __init__(self, app: FastAPI, instance_name: str = "dt"): self.broker_url: Optional[str] = os.environ.get(ENV_BROKER_URL) or None + # which transport carries the twins' streams. Resolved once, at + # plugin construction: a deployment decision, not a per-twin one. + self.stream_backend: str = stream_backend() + # the embedded DT stream broker, shared plugin-wide and started on - # first need (see `stream_addresses`) + # first need (see `stream_addresses`). Never started at all under + # the 'orbit' backend -- that is the point of it. self._stream_broker: Optional[ZMQ_BrokerProcess] = None self._stream_addrs: Optional[tuple[str, str]] = None self._stream_lock = asyncio.Lock() @@ -254,13 +270,7 @@ async def admin_sessions(self, request: Request) -> dict: entry["sid"] = sid sessions.append(entry) - return { - "sessions": sessions, - "stream_broker": { - "addresses": self._stream_addrs, - "alive": bool(self._stream_broker and self._stream_broker.is_alive()), - }, - } + return {"sessions": sessions, "stream_broker": self.stream_summary()} # -- observability ------------------------------------------------------ @@ -309,6 +319,45 @@ async def on_topology_change(self, participants: dict) -> None: sid, ", ".join(sorted(lost)), ", ".join(failed), ) + # -- the data plane ----------------------------------------------------- + + async def connect_stream( + self, namespace: str, timeout: Optional[float] = CLIENT_CONNECT_TIMEOUT + ) -> PubSubClient: + """A connected, namespaced stream client on the selected backend. + + The single place where the plugin's transport choice is applied. + The `orbit` branch never touches `stream_addresses`, which is what + keeps the embedded ZMQ broker from being started in a deployment + that asked for the token-authenticated data plane. + """ + + if self.stream_backend == BACKEND_ORBIT: + return await connect_stream_client( + namespace, + timeout=timeout, + backend=BACKEND_ORBIT, + broker_url=self.broker_url, + ) + + pub_addr, sub_addr = await self.stream_addresses() + + return await connect_stream_client(namespace, pub_addr, sub_addr, timeout) + + def stream_summary(self) -> dict: + """The data plane's entry in `admin/sessions`.""" + + if self.stream_backend == BACKEND_ORBIT: + # no addresses and nothing to supervise: the streams ride the + # ORBIT connection the control plane already has + return {"backend": BACKEND_ORBIT} + + return { + "backend": self.stream_backend, + "addresses": self._stream_addrs, + "alive": bool(self._stream_broker and self._stream_broker.is_alive()), + } + # -- embedded stream broker -------------------------------------------- async def stream_addresses(self) -> tuple[str, str]: diff --git a/src/digitaltwin/service/session.py b/src/digitaltwin/service/session.py index 4a69473..bf29d4f 100644 --- a/src/digitaltwin/service/session.py +++ b/src/digitaltwin/service/session.py @@ -23,7 +23,7 @@ from ..components import DataType, TypedData from ..runtime import DTRuntime, RuntimeState -from ..streaming import PubSubClient, connect_stream_client +from ..streaming import PubSubClient from .wire import Package, check_versions, decode, encode log = logging.getLogger("radical.orbit") @@ -669,9 +669,10 @@ async def _init_twin(self, twin: TwinInstance) -> None: flow, *_ = await asyncio.gather(*map(self.engine, names)) - pub_addr, sub_addr = await self._plugin.stream_addresses() - stream = await connect_stream_client( - twin.twin_id, pub_addr, sub_addr, STREAM_CONNECT_TIMEOUT + # the plugin owns the transport choice (zmq / orbit); the + # twin only ever sees a connected, namespaced client + stream = await self._plugin.connect_stream( + twin.twin_id, STREAM_CONNECT_TIMEOUT ) twin.ready(DTRuntime(flow, stream), stream) diff --git a/src/digitaltwin/streaming.py b/src/digitaltwin/streaming.py index 71572e2..b3a68e4 100644 --- a/src/digitaltwin/streaming.py +++ b/src/digitaltwin/streaming.py @@ -19,7 +19,13 @@ from zmq.utils.monitor import recv_monitor_message from .components import DataType, TypedData -from .config import RANDOM_PUB_ADDR, RANDOM_SUB_ADDR, stream_addresses +from .config import ( + BACKEND_ORBIT, + RANDOM_PUB_ADDR, + RANDOM_SUB_ADDR, + stream_addresses, + stream_backend, +) logger = logging.getLogger(__name__) @@ -75,6 +81,15 @@ def decode_payload(payload: bytes, codec: str): class PubSubBackend(ABC): + """The transport seam: everything above this is transport-agnostic. + + A backend delivers to per-topic subscriber callbacks from a single + receive loop it owns. That loop, the subscriber registry and the + closed/running state are the same in every backend, so they live + here; a subclass supplies `connect`, `publish`, `subscribe`, + `unsubscribe`, `close` and a `_run()` body. + """ + label = "generic" # names this backend in a PubSubConfig: what has to reopen the @@ -92,12 +107,77 @@ def __init__(self): # the failure mode this exists to prevent. self.on_error: Optional[Callable[[BaseException], None]] = None + # topic -> subscriber callbacks. Delivery is filtered by exact + # topic lookup, whatever the transport matched on the wire. + self.topics: dict[str, list[Callable]] = {} + + self._task: Optional[asyncio.Task] = None + self._closed = False + self.is_running = asyncio.Event() + def _report_error(self, exc: BaseException): logger.error("stream backend failed: %s", exc, exc_info=exc) if self.on_error is not None: self.on_error(exc) + def _check_open(self): + if self._closed: + raise RuntimeError("stream client is closed") + + async def _await_running(self, what: str): + """Callers which arrive before `connect()` finished are made to + wait rather than to lose their message.""" + + if not self.is_running.is_set(): + logger.warning("requesting %s before connecting to broker. Waiting", + what) + await self.is_running.wait() + + def _start_receiving(self): + """Arm the supervised receive loop. Called at the end of connect.""" + + self._task = asyncio.create_task(self._run()) + self._task.add_done_callback(self._run_done) + + async def _dispatch(self, topic: str, message): + """Hand one decoded message to the topic's subscribers. + + One failing subscriber must not starve its siblings + (`CancelledError` is not an `Exception`: close() still wins). + """ + + for task in self.topics.get(topic, []): + try: + await task(message) + except Exception: + logger.exception("subscriber failed on topic %r", topic) + + async def _cancel_receiving(self): + """Cancel and await the receive loop. Part of every close().""" + + task, self._task = self._task, None + if task is not None: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + def _run_done(self, task: asyncio.Task): + """The receive loop only ends on close(). Any other exit means the + twin stopped receiving -- report it instead of stalling silently.""" + + if self._closed or task.cancelled(): + return + + exc = task.exception() or RuntimeError("stream receive loop exited") + self._report_error(exc) + + async def _run(self): + """Receive loop: decode frames and `_dispatch` them. Runs until + cancelled by close().""" + + raise NotImplementedError + @abstractmethod async def connect(self, *args, **kwargs): pass @@ -308,18 +388,10 @@ def __init__(self, pub_addr: Optional[str] = None, sub_addr: Optional[str] = Non self._ctx.socket(zmq.SUB) if sub_addr is not None else None ) - # subscribe: store the callback for the topic - # publish: send a message to each of the callbacks. - - self.topics: dict[str, list[Callable]] = {} - # topics whose payload the transport must hand over untouched: # something above the seam owns their wire format (see the codecs) self.raw_topics: set[str] = set() - self._task: Optional[asyncio.Task] = None - self._closed = False - self.is_running = asyncio.Event() async def _connect_socket(self, sock, addr): """Connect `sock` and wait until the connection is established. @@ -374,8 +446,7 @@ async def connect(self, timeout: Optional[float] = CLIENT_CONNECT_TIMEOUT): raise if self.sub_soc is not None: - self._task = asyncio.create_task(self._run()) - self._task.add_done_callback(self._run_done) + self._start_receiving() self.is_running.set() @@ -384,9 +455,7 @@ async def publish(self, topic, message, raw=False): if self.pub_soc is None: raise ValueError("Publishing endpoint not connected") - if not (self.is_running.is_set()): - logger.warning("Requesting publish before connecting to broker. Waiting") - await self.is_running.wait() + await self._await_running("publish") topic_b = topic.encode("utf-8") message_b = message if raw else cloudpickle.dumps(message) @@ -397,9 +466,8 @@ async def subscribe(self, topic, callback, raw=False, **backend_params): if self.sub_soc is None: raise ValueError("Subscribe endpoint not connected") - if not (self.is_running.is_set()): - logger.warning("Requesting subscribe before connecting to broker. Waiting") - await self.is_running.wait() + await self._await_running("subscribe") + self.sub_soc.setsockopt(zmq.SUBSCRIBE, topic.encode("utf-8")) self.topics.setdefault(topic, []).append(callback) @@ -425,12 +493,8 @@ async def close(self): return self._closed = True - task, self._task = self._task, None try: - if task is not None: - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task + await self._cancel_receiving() finally: # the guard above makes close() a one-shot, so the sockets and # the context have to go even if the await was cancelled @@ -446,10 +510,6 @@ async def close(self): self._ctx.term() self.is_running.clear() - def _check_open(self): - if self._closed: - raise RuntimeError("stream client is closed") - async def _run(self): """Receive loop. A single bad payload or a raising callback must not take the stream down -- it is dropped and logged.""" @@ -467,23 +527,7 @@ async def _run(self): logger.exception("dropping malformed message: %r", frames[:1]) continue - for task in self.topics.get(item, []): - # one failing subscriber must not starve its siblings - # (CancelledError is not an Exception: close() still wins) - try: - await task(data) - except Exception: - logger.exception("subscriber failed on topic %r", item) - - def _run_done(self, task: asyncio.Task): - """The receive loop only ends on close(). Any other exit means the - twin stopped receiving -- report it instead of stalling silently.""" - - if self._closed or task.cancelled(): - return - - exc = task.exception() or RuntimeError("stream receive loop exited") - self._report_error(exc) + await self._dispatch(item, data) # The pubsub client abstracts away the specifics of the pub / sub @@ -546,6 +590,7 @@ def config(self) -> "PubSubConfig": pub_addr=self._backend.pub_addr, sub_addr=self._backend.sub_addr, kind=self._backend.kind, + broker_url=getattr(self._backend, "broker_url", None), ) def topic(self, dtype: DataType) -> str: @@ -692,12 +737,17 @@ class PubSubConfig: `kind` names the backend which has to open the endpoint; a backend declares its own (`PubSubBackend.kind`). It stays a plain string so a further backend needs no change here. + + The address fields belong to the `zmq` kind; `broker_url` to the + `orbit` kind (`None` uses ORBIT's own resolution, and the auth token + is deliberately not part of the config -- it is resolved locally). """ namespace: Optional[str] = None pub_addr: Optional[str] = None sub_addr: Optional[str] = None kind: str = ZMQ_PS_Client.kind + broker_url: Optional[str] = None @classmethod def resolve( @@ -722,13 +772,22 @@ async def connect_backend(self, timeout: Optional[float] = None) -> PubSubBacken broker leaks neither sockets nor a context. """ - if self.kind != ZMQ_PS_Client.kind: + if self.kind == BACKEND_ORBIT: + # imported here: `radical.orbit` is the optional 'service' + # extra, and a plain ZMQ install must not need it + from .streaming_orbit import OrbitPubSubBackend + + backend = OrbitPubSubBackend(self.broker_url) + + elif self.kind == ZMQ_PS_Client.kind: + backend = ZMQ_PS_Client(self.pub_addr, self.sub_addr) + + else: raise ValueError( f"cannot open a {self.kind!r} stream endpoint here:" f" no backend of that kind is available" ) - backend = ZMQ_PS_Client(self.pub_addr, self.sub_addr) await backend.connect(timeout) return backend @@ -793,11 +852,26 @@ async def connect_stream_client( pub_addr: Optional[str] = None, sub_addr: Optional[str] = None, timeout: Optional[float] = CLIENT_CONNECT_TIMEOUT, + *, + backend: Optional[str] = None, + broker_url: Optional[str] = None, ) -> PubSubClient: """Build and connect a namespaced stream client from configuration. - The connect is bounded by `timeout` -- see `ZMQ_PS_Client.connect`. + `backend` selects the transport (`zmq` / `orbit`); unset, it comes + from `DT_STREAM_BACKEND` (see `config.stream_backend`). The address + arguments belong to the ZMQ backend and `broker_url` to the ORBIT one + -- each is ignored by the other, and both default to their own + resolution. + + The connect is bounded by `timeout` in either case. """ - cfg = PubSubConfig.resolve(namespace, pub_addr, sub_addr) + name = stream_backend(backend) + + if name == BACKEND_ORBIT: + cfg = PubSubConfig(namespace, kind=BACKEND_ORBIT, broker_url=broker_url) + else: + cfg = PubSubConfig.resolve(namespace, pub_addr, sub_addr) + return await cfg.connect(timeout) diff --git a/src/digitaltwin/streaming_orbit.py b/src/digitaltwin/streaming_orbit.py new file mode 100644 index 0000000..301cee7 --- /dev/null +++ b/src/digitaltwin/streaming_orbit.py @@ -0,0 +1,337 @@ +"""The DT data plane on ORBIT eventing (DTaaS plan M3, risk R7). + +A second `PubSubBackend` behind the M0 seam. Nothing above the seam +changes: `PubSubClient` still hands topics down and gets decoded messages +back, and a persistent component still publishes through +`RuntimeAPI.stream`. What changes is where the bytes travel. + +Why this exists +--------------- +The ZMQ backend's payloads are cloudpickled and its XSUB/XPUB ports carry +no authentication: anyone who can reach them executes code in every +subscriber. This backend moves exactly the same payloads inside ORBIT's +token-authenticated WebSocket star, so the data plane is no weaker than +the control plane and the deployment opens no DT-owned ports at all. +The payloads are *still* cloudpickle -- the win is the trust boundary +around them, which is the same one that already accepts client-shipped +component classes (plan risk R4). + +How it maps +----------- +- **Publish** is `EndpointRuntime.send_notification(plugin, topic, + data)`: a plain sync, non-blocking call that emits one `event` frame. + The DT topic (`dt//dtypes/