Coverage for /usr/local/lib/python3.10/site-packages/opal_server-0.0.0-py3.10.egg/opal_server/pubsub.py: 46%

153 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 11:54 +0000

1import asyncio 

2import random 

3import time 

4from contextlib import contextmanager 

5from contextvars import ContextVar 

6from threading import Lock 

7from typing import Dict, Generator, List, Optional, Set, Tuple, Union, cast 

8from uuid import UUID, uuid4 

9 

10from fastapi import APIRouter, Depends, WebSocket 

11from fastapi_websocket_pubsub import ( 

12 ALL_TOPICS, 

13 EventBroadcaster, 

14 PubSubEndpoint, 

15 TopicList, 

16) 

17from fastapi_websocket_pubsub.event_notifier import ( 

18 EventCallback, 

19 SubscriberId, 

20 Subscription, 

21) 

22from fastapi_websocket_pubsub.websocket_rpc_event_notifier import ( 

23 WebSocketRpcEventNotifier, 

24) 

25from fastapi_websocket_rpc import RpcChannel 

26from opal_common.authentication.deps import WebsocketJWTAuthenticator 

27from opal_common.authentication.signer import JWTSigner 

28from opal_common.authentication.types import JWTClaims 

29from opal_common.authentication.verifier import Unauthorized 

30from opal_common.confi.confi import load_conf_if_none 

31from opal_common.config import opal_common_config 

32from opal_common.logger import logger 

33from opal_server.config import opal_server_config 

34from opal_server.pubsub_resilience import ( 

35 FreezablePubSubEndpoint, 

36 ReconnectingBroadcaster, 

37 SafeConnectionManager, 

38) 

39from pydantic import BaseModel 

40from starlette.datastructures import QueryParams 

41 

42OPAL_CLIENT_INFO_PARAM_PREFIX = "__opal_" 

43OPAL_CLIENT_INFO_CLIENT_ID = f"{OPAL_CLIENT_INFO_PARAM_PREFIX}client_id" 

44 

45 

46class ClientInfo(BaseModel): 

47 client_id: str 

48 source_host: Optional[str] 

49 source_port: Optional[int] 

50 connect_time: float 

51 subscribed_topics: Set[str] = set() 

52 refcount: int = 0 # Only change this while locking ClientTracker._client_lock 

53 query_params: Dict[str, str] 

54 

55 

56current_client: ContextVar[ClientInfo] = ContextVar("current_client") 

57 

58 

59class ClientTracker: 

60 def __init__(self): 

61 self._clients_by_ids: Dict[str, ClientInfo] = {} 

62 self._client_lock = Lock() 

63 

64 def clients(self) -> Dict[str, ClientInfo]: 

65 return dict(self._clients_by_ids) 

66 

67 @contextmanager 

68 def new_client( 

69 self, 

70 source_host: Optional[str], 

71 source_port: Optional[int], 

72 query_params: QueryParams, 

73 ) -> Generator[ClientInfo, None, None]: 

74 client_id = f"opal:{uuid4().hex}" 

75 if OPAL_CLIENT_INFO_CLIENT_ID in query_params: 

76 client_id = query_params.get(OPAL_CLIENT_INFO_CLIENT_ID) 

77 elif source_host is not None and source_port is not None: 

78 client_id = f"host:{source_host}:{source_port}" 

79 with self._client_lock: 

80 client_info = self._clients_by_ids.pop(client_id, None) 

81 if client_info is None: 

82 client_info = ClientInfo( 

83 client_id=client_id, 

84 source_host=source_host, 

85 source_port=source_port, 

86 connect_time=time.time(), 

87 query_params=query_params, 

88 ) 

89 client_info.refcount += 1 

90 self._clients_by_ids[client_id] = client_info 

91 yield client_info 

92 with self._client_lock: 

93 client_info = self._clients_by_ids.pop(client_id) 

94 client_info.refcount -= 1 

95 if client_info.refcount >= 1: 

96 self._clients_by_ids[client_id] = client_info 

97 

98 async def on_subscribe( 

99 self, 

100 subscriber_id: SubscriberId, 

101 topics: Union[TopicList, ALL_TOPICS], 

102 ): 

103 if not isinstance(topics, list): 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true

104 topics = [topics] 

105 

106 client_info = current_client.get(None) 

107 

108 # on_subscribe is sometimes called for the broadcaster, when there is no "current client" 

109 if client_info is not None: 109 ↛ 110line 109 didn't jump to line 110 because the condition on line 109 was never true

110 client_info.subscribed_topics.update(topics) 

111 

112 async def on_unsubscribe( 

113 self, 

114 subscriber_id: SubscriberId, 

115 topics: Union[TopicList, ALL_TOPICS], 

116 ): 

117 if not isinstance(topics, list): 117 ↛ 118line 117 didn't jump to line 118 because the condition on line 117 was never true

118 topics = [topics] 

119 

120 client_info = current_client.get(None) 

121 

122 # on_subscribe is sometimes called for the broadcaster, when there is no "current client" 

123 if client_info is not None: 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true

124 client_info.subscribed_topics.difference_update(topics) 

125 

126 

127class PubSub: 

128 """Wrapper for the Pub/Sub channel used for both policy and data 

129 updates.""" 

130 

131 def __init__(self, signer: JWTSigner, broadcaster_uri: str = None): 

132 """ 

133 Args: 

134 broadcaster_uri (str, optional): Which server/medium should the PubSub use for broadcasting. Defaults to BROADCAST_URI. 

135 None means no broadcasting. 

136 """ 

137 broadcaster_uri = load_conf_if_none( 

138 broadcaster_uri, opal_server_config.BROADCAST_URI 

139 ) 

140 self.pubsub_router = APIRouter() 

141 self.api_router = APIRouter() 

142 # Pub/Sub Internals 

143 self.notifier = WebSocketRpcEventNotifier() 

144 self.notifier.add_channel_restriction(type(self)._verify_permitted_topics) 

145 self.notifier.add_channel_restriction(type(self)._reject_external_purge_channel) 

146 self.client_tracker = ClientTracker() 

147 self.notifier.register_subscribe_event(self.client_tracker.on_subscribe) 

148 self.notifier.register_unsubscribe_event(self.client_tracker.on_unsubscribe) 

149 

150 self.broadcaster = None 

151 if broadcaster_uri is not None: 151 ↛ 152line 151 didn't jump to line 152 because the condition on line 151 was never true

152 if opal_server_config.BROADCAST_RECONNECT_ENABLED: 

153 logger.info( 

154 "Initializing reconnecting broadcaster for server<->server communication" 

155 ) 

156 self.broadcaster = ReconnectingBroadcaster( 

157 broadcaster_uri, 

158 notifier=self.notifier, 

159 channel=opal_server_config.BROADCAST_CHANNEL_NAME, 

160 reconnect_max_retries=opal_server_config.BROADCAST_RECONNECT_MAX_RETRIES, 

161 reconnect_backoff_min=opal_server_config.BROADCAST_RECONNECT_BACKOFF_MIN_SECONDS, 

162 reconnect_backoff_max=opal_server_config.BROADCAST_RECONNECT_BACKOFF_MAX_SECONDS, 

163 replay_buffer_size=opal_server_config.BROADCAST_REPLAY_BUFFER_SIZE, 

164 resync_settle_seconds=opal_server_config.BROADCAST_RESYNC_SETTLE_SECONDS, 

165 ) 

166 else: 

167 logger.info( 

168 "Initializing broadcaster for server<->server communication" 

169 ) 

170 self.broadcaster = EventBroadcaster( 

171 broadcaster_uri, 

172 notifier=self.notifier, 

173 channel=opal_server_config.BROADCAST_CHANNEL_NAME, 

174 ) 

175 else: 

176 logger.info("Pub/Sub broadcaster is off") 

177 

178 # The server endpoint. 

179 # 

180 # ignore_broadcaster_disconnected=False races each client's main_loop against the 

181 # shared broadcaster reader task, so a completed reader surfaces the disconnect and 

182 # the client reconnects. That is only safe with ReconnectingBroadcaster, whose 

183 # reader stays *pending* across transient drops and completes only after 

184 # BROADCAST_RECONNECT_MAX_RETRIES is exhausted (the intended last resort). For the 

185 # stock EventBroadcaster (reconnect disabled) the reader dies on the first drop, so 

186 # we keep the library-safe default (True) to degrade to "stale but connected" 

187 # rather than the fleet-wide drop storm. (Replaces an earlier experimental 

188 # broadcast-connection-loss flag.) 

189 # The reconnect resync is the freeze's ONLY recovery path (frozen publishes are 

190 # dropped, not buffered) — with the resync disabled the combination would silently 

191 # lose every update published during every gap, so refuse it rather than honor it. 

192 # Both guardrails apply only where a freeze can actually engage, i.e. when the 

193 # reconnecting broadcaster (the gap signal) was built above — warning a single- 

194 # worker or reconnect-disabled deployment about the resync would be misdirection. 

195 freeze_on_disconnect = opal_server_config.BROADCAST_FREEZE_ON_DISCONNECT 

196 if freeze_on_disconnect and isinstance( 196 ↛ 199line 196 didn't jump to line 199 because the condition on line 196 was never true

197 self.broadcaster, ReconnectingBroadcaster 

198 ): 

199 if not opal_server_config.BROADCAST_RESYNC_ON_RECONNECT: 

200 logger.warning( 

201 "BROADCAST_FREEZE_ON_DISCONNECT is enabled but BROADCAST_RESYNC_ON_RECONNECT " 

202 "is disabled — the resync is the freeze's only recovery path, so freezing is " 

203 "DISABLED to avoid silently losing updates published during a backbone gap. " 

204 "Re-enable BROADCAST_RESYNC_ON_RECONNECT to get the fleet-consistency freeze." 

205 ) 

206 freeze_on_disconnect = False 

207 elif freeze_on_disconnect and self.broadcaster is not None: 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true

208 logger.info( 

209 "BROADCAST_FREEZE_ON_DISCONNECT is enabled but BROADCAST_RECONNECT_ENABLED " 

210 "is disabled — the freeze engages only on the reconnecting broadcaster's " 

211 "backbone-gap signal, so it is a no-op with the stock broadcaster." 

212 ) 

213 self.endpoint = FreezablePubSubEndpoint( 

214 broadcaster=self.broadcaster, 

215 notifier=self.notifier, 

216 rpc_channel_get_remote_id=opal_common_config.STATISTICS_ENABLED, 

217 ignore_broadcaster_disconnected=not isinstance( 

218 self.broadcaster, ReconnectingBroadcaster 

219 ), 

220 # Freeze client-facing publishes during a backbone gap so a write that cannot 

221 # reach the whole fleet is not applied on a single worker (see 

222 # FreezablePubSubEndpoint). No-op unless the reconnecting broadcaster is in use. 

223 freeze_on_disconnect=freeze_on_disconnect, 

224 # Exempt: the git-webhook trigger (targets the server-side policy watcher, not 

225 # clients — freezing it would drop repo-pull triggers with nothing to replay 

226 # them) and the server-to-server coordination channels (statistics + broadcaster 

227 # keepalive — dropping those corrupts state no resync rebuilds). The coordination 

228 # channels are exempted by their CONFIGURED names: the endpoint's own "__" prefix 

229 # rule covers only the defaults, and every one of these is operator-overridable. 

230 # Worker-to-worker cache purge on scope delete/repoint. 

231 # Freezing it during a backbone gap would leave stale GitPolicyFetcher 

232 # caches fleet-wide with nothing to replay the purge. Exempt by its 

233 # CONFIGURED name because it is operator-overridable to a non-"__" value. 

234 freeze_exempt_topics=[ 

235 opal_server_config.POLICY_REPO_WEBHOOK_TOPIC, 

236 opal_server_config.BROADCAST_KEEPALIVE_TOPIC, 

237 opal_server_config.STATISTICS_WAKEUP_CHANNEL, 

238 opal_server_config.STATISTICS_STATE_SYNC_CHANNEL, 

239 opal_server_config.STATISTICS_SERVER_KEEPALIVE_CHANNEL, 

240 opal_common_config.STATISTICS_ADD_CLIENT_CHANNEL, 

241 opal_common_config.STATISTICS_REMOVE_CLIENT_CHANNEL, 

242 opal_server_config.SCOPES_PURGE_CHANNEL, 

243 ], 

244 ) 

245 # fastapi_websocket_rpc's ConnectionManager.disconnect is not idempotent: the RPC 

246 # endpoint can call it twice for one socket (handle_disconnect plus the outer 

247 # except in WebsocketRPCEndpoint.main_loop), raising 

248 # ValueError('list.remove(x): x not in list'). Swap in an idempotent manager 

249 # before any connection is served. Reaching into the wrapped endpoint is the 

250 # only injection point the library offers (PubSubEndpoint takes no manager), 

251 # so fail loudly if its internals ever move (a bare assert would be stripped 

252 # under python -O, silently restoring the storm). 

253 if not ( 253 ↛ 257line 253 didn't jump to line 257 because the condition on line 253 was never true

254 hasattr(self.endpoint, "endpoint") 

255 and hasattr(self.endpoint.endpoint, "manager") 

256 ): 

257 raise RuntimeError( 

258 "Unexpected fastapi_websocket_pubsub internals: cannot install " 

259 "SafeConnectionManager (endpoint.endpoint.manager not found)" 

260 ) 

261 self.endpoint.endpoint.manager = SafeConnectionManager() 

262 

263 if isinstance(self.broadcaster, ReconnectingBroadcaster): 263 ↛ 264line 263 didn't jump to line 264 because the condition on line 263 was never true

264 self._wire_broadcaster_resync() 

265 

266 authenticator = WebsocketJWTAuthenticator(signer) 

267 

268 @self.api_router.get( 

269 "/pubsub_client_info", response_model=Dict[str, ClientInfo] 

270 ) 

271 async def client_info(): 

272 return self.client_tracker.clients() 

273 

274 @self.pubsub_router.websocket("/ws") 

275 async def websocket_rpc_endpoint( 

276 websocket: WebSocket, claims: Optional[JWTClaims] = Depends(authenticator) 

277 ): 

278 """This is the main websocket endpoint the sidecar uses to register 

279 on policy updates. 

280 

281 as you can see, this endpoint is protected by an HTTP 

282 Authorization Bearer token. 

283 """ 

284 try: 

285 if claims is None: 

286 logger.info( 

287 "Closing connection, remote address: {remote_address}", 

288 remote_address=websocket.client, 

289 reason="Authentication failed", 

290 ) 

291 return 

292 

293 source_host = None 

294 source_port = None 

295 if websocket.client is not None: 

296 source_host = websocket.client.host 

297 source_port = websocket.client.port 

298 with self.client_tracker.new_client( 

299 source_host, source_port, websocket.query_params 

300 ) as client_info: 

301 token = current_client.set(client_info) 

302 try: 

303 await self.endpoint.main_loop(websocket, claims=claims) 

304 finally: 

305 current_client.reset(token) 

306 finally: 

307 await websocket.close() 

308 

309 def _wire_broadcaster_resync(self): 

310 """Register the post-gap resync. 

311 

312 After any backbone gap, force this worker's clients to reconnect 

313 so they re-run their full (scope-aware) policy + data 

314 reconciliation. Every worker hit the same gap, so each 

315 reconciles its own clients and the fleet converges — this is the 

316 consistency guarantee; the broadcaster's replay buffer only 

317 narrows the staleness window. 

318 """ 

319 manager = self.endpoint.endpoint.manager 

320 broadcaster = self.broadcaster 

321 resync_enabled = opal_server_config.BROADCAST_RESYNC_ON_RECONNECT 

322 settle = opal_server_config.BROADCAST_RESYNC_SETTLE_SECONDS 

323 

324 async def _on_broadcaster_reconnect(): 

325 if not resync_enabled: 

326 logger.info("Broadcaster recovered after a gap; client resync disabled") 

327 return 

328 # Every worker hit the same gap; add a per-worker random delay so the 

329 # fleet does not recycle its clients in lockstep. 

330 if settle > 0: 

331 await asyncio.sleep(random.uniform(0, settle)) 

332 logger.warning( 

333 "Broadcaster recovered after a gap; resyncing this worker's clients " 

334 "so they re-fetch current policy + data state" 

335 ) 

336 # The reader is kept alive across this recycle by the listening context the 

337 # broadcaster pins around the whole recovery (see _recover_after_gap), so 

338 # closing every client here cannot cancel it. 

339 await manager.close_all_staggered() 

340 if settle > 0: 

341 await asyncio.sleep(settle) 

342 

343 broadcaster.set_reconnect_callback(_on_broadcaster_reconnect) 

344 

345 @staticmethod 

346 async def _verify_permitted_topics( 

347 topics: Union[TopicList, ALL_TOPICS], channel: RpcChannel 

348 ): 

349 if "permitted_topics" not in channel.context.get("claims", {}): 

350 return 

351 unauthorized_topics = set(topics).difference( 

352 channel.context["claims"]["permitted_topics"] 

353 ) 

354 if unauthorized_topics: 

355 raise Unauthorized( 

356 description=f"Invalid 'topics' to subscribe {unauthorized_topics}" 

357 ) 

358 

359 @staticmethod 

360 async def _reject_external_purge_channel( 

361 topics: Union[TopicList, ALL_TOPICS], channel: RpcChannel 

362 ): 

363 """Forbid external RPC peers from touching the scope-purge channel. 

364 

365 ``SCOPES_PURGE_CHANNEL`` is a server-internal control channel: a purge 

366 command evicts every worker's ``GitPolicyFetcher`` caches fleet-wide. 

367 The only legitimate publishers 

368 are opal-server itself (delete / repoint) and the 

369 cross-server broadcaster relay — both call ``notify()`` with 

370 ``channel=None``, and channel restrictions run **only when a channel is 

371 present** (see ``EventNotifier.notify``/``subscribe``: ``if channel:``). 

372 So this restriction never fires for legitimate server traffic; it only 

373 sees an external websocket peer (a client/PDP). 

374 

375 Without this gate any connected peer could forge a purge and churn the 

376 whole fleet's caches, because ``_verify_permitted_topics`` above 

377 default-allows tokens that carry no ``permitted_topics`` claim (the 

378 common case). No legitimate client ever names this channel — clients 

379 only publish to ``STATISTICS_ADD_CLIENT_CHANNEL`` — so rejecting it here 

380 blocks the forgery without affecting any real client (publish or 

381 subscribe); no client change/redeploy is required. 

382 

383 ``ALL_TOPICS`` is rejected too. It is exempt for *publish* (a publish 

384 never fans out to a specific-topic subscriber through it), but the same 

385 callback also guards *subscribe*, and ``EventNotifier.notify`` fans 

386 every published topic — the purge channel included — to the 

387 ``ALL_TOPICS`` subscriber bucket. So a peer subscribing to 

388 ``ALL_TOPICS`` would still receive purge traffic. No opal-client ever 

389 subscribes to ``ALL_TOPICS`` (only the broadcaster does, and that is 

390 ``channel=None`` so it never reaches this callback), so rejecting it 

391 from external peers is safe. 

392 """ 

393 # Normalize: the notifier may hand us a single topic (str), a list, or 

394 # the ALL_TOPICS sentinel (also a str). An external peer may name 

395 # neither the purge channel nor ALL_TOPICS. 

396 topic_list = [topics] if isinstance(topics, str) else list(topics) 

397 if ( 

398 ALL_TOPICS in topic_list 

399 or opal_server_config.SCOPES_PURGE_CHANNEL in topic_list 

400 ): 

401 raise Unauthorized( 

402 description=( 

403 f"Topic '{opal_server_config.SCOPES_PURGE_CHANNEL}' (and " 

404 "ALL_TOPICS, which would receive it) is server-internal and " 

405 "may not be published or subscribed by external peers" 

406 ) 

407 )