sovereign 0.19.3__py3-none-any.whl → 1.0.0a4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of sovereign might be problematic. Click here for more details.

Files changed (99) hide show
  1. sovereign/__init__.py +13 -81
  2. sovereign/app.py +62 -48
  3. sovereign/cache/__init__.py +245 -0
  4. sovereign/cache/backends/__init__.py +110 -0
  5. sovereign/cache/backends/s3.py +161 -0
  6. sovereign/cache/filesystem.py +74 -0
  7. sovereign/cache/types.py +17 -0
  8. sovereign/configuration.py +607 -0
  9. sovereign/constants.py +1 -0
  10. sovereign/context.py +270 -104
  11. sovereign/dynamic_config/__init__.py +112 -0
  12. sovereign/dynamic_config/deser.py +78 -0
  13. sovereign/dynamic_config/loaders.py +120 -0
  14. sovereign/error_info.py +2 -3
  15. sovereign/events.py +49 -0
  16. sovereign/logging/access_logger.py +85 -0
  17. sovereign/logging/application_logger.py +54 -0
  18. sovereign/logging/base_logger.py +41 -0
  19. sovereign/logging/bootstrapper.py +36 -0
  20. sovereign/logging/types.py +10 -0
  21. sovereign/middlewares.py +8 -7
  22. sovereign/modifiers/lib.py +2 -1
  23. sovereign/rendering.py +124 -0
  24. sovereign/rendering_common.py +91 -0
  25. sovereign/response_class.py +18 -0
  26. sovereign/server.py +112 -35
  27. sovereign/statistics.py +19 -21
  28. sovereign/templates/base.html +59 -46
  29. sovereign/templates/resources.html +203 -102
  30. sovereign/testing/loaders.py +9 -0
  31. sovereign/{modifiers/test.py → testing/modifiers.py} +0 -2
  32. sovereign/tracing.py +103 -0
  33. sovereign/types.py +304 -0
  34. sovereign/utils/auth.py +27 -13
  35. sovereign/utils/crypto/__init__.py +0 -0
  36. sovereign/utils/crypto/crypto.py +135 -0
  37. sovereign/utils/crypto/suites/__init__.py +21 -0
  38. sovereign/utils/crypto/suites/aes_gcm_cipher.py +42 -0
  39. sovereign/utils/crypto/suites/base_cipher.py +21 -0
  40. sovereign/utils/crypto/suites/disabled_cipher.py +25 -0
  41. sovereign/utils/crypto/suites/fernet_cipher.py +29 -0
  42. sovereign/utils/dictupdate.py +3 -2
  43. sovereign/utils/eds.py +40 -22
  44. sovereign/utils/entry_point_loader.py +2 -2
  45. sovereign/utils/mock.py +56 -17
  46. sovereign/utils/resources.py +17 -0
  47. sovereign/utils/templates.py +4 -2
  48. sovereign/utils/timer.py +5 -3
  49. sovereign/utils/version_info.py +8 -0
  50. sovereign/utils/weighted_clusters.py +2 -1
  51. sovereign/v2/__init__.py +0 -0
  52. sovereign/v2/data/data_store.py +621 -0
  53. sovereign/v2/data/render_discovery_response.py +24 -0
  54. sovereign/v2/data/repositories.py +90 -0
  55. sovereign/v2/data/utils.py +33 -0
  56. sovereign/v2/data/worker_queue.py +273 -0
  57. sovereign/v2/jobs/refresh_context.py +117 -0
  58. sovereign/v2/jobs/render_discovery_job.py +145 -0
  59. sovereign/v2/logging.py +81 -0
  60. sovereign/v2/types.py +41 -0
  61. sovereign/v2/web.py +101 -0
  62. sovereign/v2/worker.py +199 -0
  63. sovereign/views/__init__.py +7 -0
  64. sovereign/views/api.py +82 -0
  65. sovereign/views/crypto.py +46 -15
  66. sovereign/views/discovery.py +55 -119
  67. sovereign/views/healthchecks.py +107 -20
  68. sovereign/views/interface.py +171 -111
  69. sovereign/worker.py +193 -0
  70. {sovereign-0.19.3.dist-info → sovereign-1.0.0a4.dist-info}/METADATA +80 -76
  71. sovereign-1.0.0a4.dist-info/RECORD +85 -0
  72. {sovereign-0.19.3.dist-info → sovereign-1.0.0a4.dist-info}/WHEEL +1 -1
  73. sovereign-1.0.0a4.dist-info/entry_points.txt +46 -0
  74. sovereign_files/__init__.py +0 -0
  75. sovereign_files/static/darkmode.js +51 -0
  76. sovereign_files/static/node_expression.js +42 -0
  77. sovereign_files/static/panel.js +76 -0
  78. sovereign_files/static/resources.css +246 -0
  79. sovereign_files/static/resources.js +642 -0
  80. sovereign_files/static/sass/style.scss +33 -0
  81. sovereign_files/static/style.css +16143 -0
  82. sovereign_files/static/style.css.map +1 -0
  83. sovereign/config_loader.py +0 -225
  84. sovereign/discovery.py +0 -175
  85. sovereign/logs.py +0 -131
  86. sovereign/schemas.py +0 -780
  87. sovereign/sources/__init__.py +0 -3
  88. sovereign/sources/file.py +0 -21
  89. sovereign/sources/inline.py +0 -38
  90. sovereign/sources/lib.py +0 -40
  91. sovereign/sources/poller.py +0 -294
  92. sovereign/static/sass/style.scss +0 -27
  93. sovereign/static/style.css +0 -13553
  94. sovereign/templates/ul_filter.html +0 -22
  95. sovereign/utils/crypto.py +0 -103
  96. sovereign/views/admin.py +0 -120
  97. sovereign-0.19.3.dist-info/LICENSE.txt +0 -13
  98. sovereign-0.19.3.dist-info/RECORD +0 -47
  99. sovereign-0.19.3.dist-info/entry_points.txt +0 -10
@@ -1,77 +1,62 @@
1
- from typing import List, Dict, Any
1
+ import json
2
+ import logging
2
3
  from collections import defaultdict
3
- from fastapi import APIRouter, Query, Path, Cookie
4
+ from typing import Any, Dict, List
5
+
6
+ from fastapi import APIRouter, Cookie, Path, Query
4
7
  from fastapi.encoders import jsonable_encoder
5
8
  from fastapi.requests import Request
6
- from fastapi.responses import RedirectResponse, JSONResponse, Response
7
- from sovereign import html_templates, XDS_TEMPLATES
8
- from sovereign.discovery import DiscoveryTypes
9
- from sovereign import poller, json_response_class
10
- from sovereign.utils.mock import mock_discovery_request
11
- from sovereign.views.discovery import perform_discovery
9
+ from fastapi.responses import HTMLResponse, JSONResponse, Response
10
+ from starlette.templating import Jinja2Templates
11
+ from structlog.typing import FilteringBoundLogger
12
+
13
+ from sovereign import __version__
14
+ from sovereign.cache import Entry
15
+ from sovereign.configuration import XDS_TEMPLATES, ConfiguredResourceTypes, config
16
+ from sovereign.response_class import json_response_class
17
+ from sovereign.utils.mock import NodeExpressionError, mock_discovery_request
18
+ from sovereign.utils.resources import get_package_file
19
+ from sovereign.v2.logging import get_named_logger
20
+ from sovereign.v2.web import wait_for_discovery_response
21
+ from sovereign.views import reader
12
22
 
13
23
  router = APIRouter()
14
24
 
15
- all_types = [t.value for t in DiscoveryTypes]
25
+ all_types = [t.value for t in ConfiguredResourceTypes]
26
+
27
+ html_templates = Jinja2Templates(
28
+ directory=str(get_package_file("sovereign", "templates"))
29
+ )
16
30
 
17
31
 
18
32
  @router.get("/")
19
- async def ui_main(request: Request) -> Response:
33
+ @router.get("/resources")
34
+ async def ui_main(request: Request) -> HTMLResponse:
20
35
  try:
21
36
  return html_templates.TemplateResponse(
37
+ request=request,
22
38
  name="base.html",
23
39
  media_type="text/html",
24
- context={
25
- "request": request,
26
- "all_types": all_types,
27
- "last_update": str(poller.last_updated),
28
- },
40
+ context={"all_types": all_types, "sovereign_version": __version__},
29
41
  )
30
42
  except IndexError:
31
43
  return html_templates.TemplateResponse(
44
+ request=request,
32
45
  name="err.html",
33
46
  media_type="text/html",
34
47
  context={
35
- "request": request,
36
48
  "title": "No resource types configured",
37
- "message": "A template should be defined for every resource "
38
- "type that you want your envoy proxies to discover.",
39
- "doc_link": "https://vsyrakis.bitbucket.io/sovereign/docs/tutorial/templates/",
49
+ "message": (
50
+ "A template should be defined for every resource "
51
+ "type that you want your envoy proxies to discover."
52
+ ),
53
+ "doc_link": "https://developer.atlassian.com/platform/sovereign/tutorial/templates/#templates",
54
+ "sovereign_version": __version__,
40
55
  },
41
56
  )
42
57
 
43
58
 
44
- @router.get(
45
- "/set-version", summary="Filter the UI by a certain Envoy Version (stores a Cookie)"
46
- )
47
- async def set_envoy_version(
48
- request: Request,
49
- version: str = Query(
50
- "__any__", title="The clients envoy version to emulate in this XDS request"
51
- ),
52
- ) -> Response:
53
- url = request.headers.get("Referer", "/ui")
54
- response = RedirectResponse(url=url)
55
- response.set_cookie(key="envoy_version", value=version)
56
- return response
57
-
58
-
59
- @router.get(
60
- "/set-service-cluster",
61
- summary="Filter the UI by a certain service cluster (stores a Cookie)",
62
- )
63
- async def set_service_cluster(
64
- request: Request,
65
- service_cluster: str = Query(
66
- "__any__", title="The clients envoy version to emulate in this XDS request"
67
- ),
68
- ) -> Response:
69
- url = request.headers.get("Referer", "/ui")
70
- response = RedirectResponse(url=url)
71
- response.set_cookie(key="service_cluster", value=service_cluster)
72
- return response
73
-
74
-
59
+ # noinspection DuplicatedCode
75
60
  @router.get(
76
61
  "/resources/{xds_type}", summary="List available resources for a given xDS type"
77
62
  )
@@ -82,47 +67,84 @@ async def resources(
82
67
  None, title="The clients region to emulate in this XDS request"
83
68
  ),
84
69
  api_version: str = Query("v2", title="The desired Envoy API version"),
85
- service_cluster: str = Cookie(
86
- "*", title="The clients service cluster to emulate in this XDS request"
70
+ node_expression: str = Cookie(
71
+ "cluster=*", title="Node expression to filter resources with"
87
72
  ),
88
73
  envoy_version: str = Cookie(
89
74
  "__any__", title="The clients envoy version to emulate in this XDS request"
90
75
  ),
91
- ) -> Response:
76
+ debug: int = Query(0, title="Show debug information on errors"),
77
+ ) -> HTMLResponse:
78
+ logger: FilteringBoundLogger = get_named_logger(
79
+ f"{__name__}.{resources.__qualname__} ({__file__})",
80
+ level=logging.DEBUG,
81
+ )
82
+
92
83
  ret: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
93
84
  try:
94
- response = await perform_discovery(
95
- req=mock_discovery_request(
96
- service_cluster=service_cluster,
97
- resource_names=[],
98
- version=envoy_version,
99
- region=region,
100
- ),
101
- api_version=api_version,
102
- resource_type=xds_type,
103
- skip_auth=True,
85
+ mock_request = mock_discovery_request(
86
+ api_version,
87
+ xds_type,
88
+ version=envoy_version,
89
+ region=region,
90
+ expressions=node_expression.split(),
104
91
  )
105
- except KeyError:
106
- ret["resources"] = []
92
+ clear_cookie = False
93
+ error = None
94
+ except NodeExpressionError as e:
95
+ mock_request = mock_discovery_request(
96
+ api_version,
97
+ xds_type,
98
+ version=envoy_version,
99
+ region=region,
100
+ )
101
+ clear_cookie = True
102
+ error = str(e)
103
+
104
+ logger.debug("Making mock request", mock_request=mock_request)
105
+
106
+ entry: Entry | None = None
107
+
108
+ if config.worker_v2_enabled:
109
+ # we're set up to use v2 of the worker
110
+ discovery_response = await wait_for_discovery_response(mock_request)
111
+ if discovery_response is not None:
112
+ entry = Entry(
113
+ text=discovery_response.model_dump_json(indent=None),
114
+ len=len(discovery_response.resources),
115
+ version=discovery_response.version_info,
116
+ node=mock_request.node,
117
+ )
118
+
107
119
  else:
108
- ret["resources"] += response.deserialize_resources()
109
- return html_templates.TemplateResponse(
120
+ entry = await reader.blocking_read(mock_request) # ty: ignore[possibly-missing-attribute]
121
+
122
+ if entry:
123
+ ret["resources"] = json.loads(entry.text).get("resources", [])
124
+
125
+ resp = html_templates.TemplateResponse(
126
+ request=request,
110
127
  name="resources.html",
111
128
  media_type="text/html",
112
129
  context={
130
+ "show_debuginfo": True if debug else False,
131
+ "discovery_response": entry,
132
+ "discovery_request": mock_request,
113
133
  "resources": ret["resources"],
114
- "request": request,
115
134
  "resource_type": xds_type,
116
135
  "all_types": all_types,
117
136
  "version": envoy_version,
118
137
  "available_versions": list(XDS_TEMPLATES.keys()),
119
- "service_cluster": service_cluster,
120
- "available_service_clusters": poller.match_keys,
121
- "last_update": str(poller.last_updated),
138
+ "error": error,
139
+ "sovereign_version": __version__,
122
140
  },
123
141
  )
142
+ if clear_cookie:
143
+ resp.delete_cookie("node_expression", path="/ui/resources/")
144
+ return resp
124
145
 
125
146
 
147
+ # noinspection DuplicatedCode
126
148
  @router.get(
127
149
  "/resources/{xds_type}/{resource_name}",
128
150
  summary="Return JSON representation of a resource",
@@ -134,25 +156,56 @@ async def resource(
134
156
  None, title="The clients region to emulate in this XDS request"
135
157
  ),
136
158
  api_version: str = Query("v2", title="The desired Envoy API version"),
137
- service_cluster: str = Cookie(
138
- "*", title="The clients service cluster to emulate in this XDS request"
159
+ node_expression: str = Cookie(
160
+ "cluster=*", title="Node expression to filter resources with"
139
161
  ),
140
162
  envoy_version: str = Cookie(
141
163
  "__any__", title="The clients envoy version to emulate in this XDS request"
142
164
  ),
143
165
  ) -> Response:
144
- response = await perform_discovery(
145
- req=mock_discovery_request(
146
- service_cluster=service_cluster,
147
- resource_names=[resource_name],
148
- version=envoy_version,
149
- region=region,
150
- ),
151
- api_version=api_version,
152
- resource_type=xds_type,
153
- skip_auth=True,
166
+ logger: FilteringBoundLogger = get_named_logger(
167
+ f"{__name__}.{resources.__qualname__} ({__file__})",
168
+ level=logging.DEBUG,
169
+ )
170
+
171
+ mock_request = mock_discovery_request(
172
+ api_version,
173
+ xds_type,
174
+ version=envoy_version,
175
+ region=region,
176
+ expressions=node_expression.split(),
177
+ )
178
+
179
+ logger.debug("Making mock request", mock_request=mock_request)
180
+
181
+ entry: Entry | None = None
182
+
183
+ if config.worker_v2_enabled:
184
+ # we're set up to use v2 of the worker
185
+ discovery_response = await wait_for_discovery_response(mock_request)
186
+ if discovery_response is not None:
187
+ entry = Entry(
188
+ text=discovery_response.model_dump_json(indent=None),
189
+ len=len(discovery_response.resources),
190
+ version=discovery_response.version_info,
191
+ node=mock_request.node,
192
+ )
193
+
194
+ else:
195
+ entry = await reader.blocking_read(mock_request) # ty: ignore[possibly-missing-attribute]
196
+
197
+ if entry:
198
+ for res in json.loads(entry.text).get("resources", []):
199
+ if res.get("name", res.get("cluster_name")) == resource_name:
200
+ safe_response = jsonable_encoder(res)
201
+ try:
202
+ return json_response_class(content=safe_response)
203
+ except TypeError:
204
+ return JSONResponse(content=safe_response)
205
+ return Response(
206
+ json.dumps({"title": "No resources found", "status": 404}),
207
+ media_type="application/json+problem",
154
208
  )
155
- return Response(response.rendered, media_type="application/json")
156
209
 
157
210
 
158
211
  @router.get(
@@ -166,36 +219,43 @@ async def virtual_hosts(
166
219
  None, title="The clients region to emulate in this XDS request"
167
220
  ),
168
221
  api_version: str = Query("v2", title="The desired Envoy API version"),
169
- service_cluster: str = Cookie(
170
- "*", title="The clients service cluster to emulate in this XDS request"
222
+ node_expression: str = Cookie(
223
+ "cluster=*", title="Node expression to filter resources with"
171
224
  ),
172
225
  envoy_version: str = Cookie(
173
226
  "__any__", title="The clients envoy version to emulate in this XDS request"
174
227
  ),
175
228
  ) -> Response:
176
- response = await perform_discovery(
177
- req=mock_discovery_request(
178
- service_cluster=service_cluster,
179
- resource_names=[route_configuration],
180
- version=envoy_version,
181
- region=region,
182
- ),
183
- api_version=api_version,
184
- resource_type="routes",
185
- skip_auth=True,
229
+ logger: FilteringBoundLogger = get_named_logger(
230
+ f"{__name__}.{virtual_hosts.__qualname__} ({__file__})",
231
+ level=logging.DEBUG,
232
+ )
233
+
234
+ mock_request = mock_discovery_request(
235
+ api_version,
236
+ "routes",
237
+ version=envoy_version,
238
+ region=region,
239
+ expressions=node_expression.split(),
240
+ )
241
+
242
+ logger.debug("Making mock request", mock_request=mock_request)
243
+
244
+ if response := await reader.blocking_read(mock_request): # ty: ignore[possibly-missing-attribute]
245
+ route_configs = [
246
+ resource_
247
+ for resource_ in json.loads(response.text).get("resources", [])
248
+ if resource_["name"] == route_configuration
249
+ ]
250
+ for route_config in route_configs:
251
+ for vhost in route_config["virtual_hosts"]:
252
+ if vhost["name"] == virtual_host:
253
+ safe_response = jsonable_encoder(vhost)
254
+ try:
255
+ return json_response_class(content=safe_response)
256
+ except TypeError:
257
+ return JSONResponse(content=safe_response)
258
+ return Response(
259
+ json.dumps({"title": "No resources found", "status": 404}),
260
+ media_type="application/json+problem",
186
261
  )
187
- route_configs = [
188
- resource_
189
- for resource_ in response.deserialize_resources()
190
- if resource_["name"] == route_configuration
191
- ]
192
- for route_config in route_configs:
193
- for vhost in route_config["virtual_hosts"]:
194
- if vhost["name"] == virtual_host:
195
- safe_response = jsonable_encoder(vhost)
196
- try:
197
- return json_response_class(content=safe_response)
198
- except TypeError:
199
- return JSONResponse(content=safe_response)
200
- break
201
- return JSONResponse(content={})
sovereign/worker.py ADDED
@@ -0,0 +1,193 @@
1
+ import asyncio
2
+ from contextlib import asynccontextmanager
3
+ from typing import final
4
+
5
+ from fastapi import Body, FastAPI
6
+
7
+ from sovereign import (
8
+ application_logger as log,
9
+ )
10
+ from sovereign import (
11
+ cache,
12
+ disabled_ciphersuite,
13
+ rendering,
14
+ server_cipher_container,
15
+ stats,
16
+ )
17
+ from sovereign.configuration import config
18
+ from sovereign.context import TemplateContext
19
+ from sovereign.events import Topic, bus
20
+ from sovereign.types import DiscoveryRequest, RegisterClientRequest
21
+
22
+
23
+ # noinspection PyUnusedLocal
24
+ def hidden_field(*args, **kwargs):
25
+ return "(value hidden)"
26
+
27
+
28
+ def inject_builtin_items(request, output):
29
+ output["__hide_from_ui"] = lambda v: v
30
+ output["crypto"] = server_cipher_container
31
+ if request.is_internal_request:
32
+ output["__hide_from_ui"] = hidden_field
33
+ output["crypto"] = disabled_ciphersuite
34
+
35
+
36
+ template_context = TemplateContext.from_config()
37
+ context_middleware = [inject_builtin_items]
38
+ template_context.middleware = context_middleware
39
+ writer = cache.CacheWriter()
40
+
41
+ ClientId = str
42
+ OnDemandJob = tuple[ClientId, DiscoveryRequest]
43
+
44
+
45
+ @final
46
+ class RenderQueue:
47
+ def __init__(self, maxsize: int = 0):
48
+ self._queue: asyncio.Queue[OnDemandJob] = asyncio.Queue(maxsize)
49
+ self._set: set[ClientId] = set()
50
+ self._lock = asyncio.Lock()
51
+
52
+ async def put(self, item: OnDemandJob):
53
+ cid = item[0]
54
+ async with self._lock:
55
+ if cid not in self._set:
56
+ await self._queue.put(item)
57
+ self._set.add(cid)
58
+
59
+ def put_nowait(self, item: OnDemandJob):
60
+ cid = item[0]
61
+ if cid in self._set:
62
+ return
63
+ if self._queue.full():
64
+ raise asyncio.QueueFull
65
+ self._queue.put_nowait(item)
66
+ self._set.add(cid)
67
+
68
+ async def get(self):
69
+ return await self._queue.get()
70
+
71
+ def full(self):
72
+ return self._queue.full()
73
+
74
+ async def task_done(self, cid):
75
+ async with self._lock:
76
+ self._set.remove(cid)
77
+ self._queue.task_done()
78
+
79
+
80
+ ONDEMAND = RenderQueue()
81
+
82
+
83
+ poller = None
84
+ if config.sources is not None:
85
+ if config.matching is not None:
86
+ matching_enabled = config.matching.enabled
87
+ node_key: str | None = config.matching.node_key
88
+ source_key: str | None = config.matching.source_key
89
+ else:
90
+ matching_enabled = False
91
+ node_key = None
92
+ source_key = None
93
+
94
+
95
+ async def render_on_event(ctx):
96
+ subscription = bus.subscribe(Topic.CONTEXT)
97
+ while True:
98
+ # block forever until new context arrives
99
+ event = await subscription.get()
100
+ context_name = event.metadata.get("name")
101
+
102
+ log.debug(event.message)
103
+ try:
104
+ if registered := writer.get_registered_clients():
105
+ size = len(registered)
106
+ stats.increment("template.render_on_event", tags=[f"batch_size:{size}"])
107
+
108
+ for client, request in registered:
109
+ if context_name in request.template.depends_on:
110
+ log.info(
111
+ f"Rendering template on-event for {request} because {context_name} was updated"
112
+ )
113
+ job = rendering.RenderJob(
114
+ id=client,
115
+ request=request,
116
+ context=ctx.get_context(request),
117
+ )
118
+ job.submit()
119
+
120
+ finally:
121
+ await asyncio.sleep(config.template_context.cooldown)
122
+
123
+
124
+ async def render_on_demand(ctx):
125
+ while True:
126
+ cid, request = await ONDEMAND.get()
127
+ stats.increment("template.render_on_demand")
128
+ log.debug(
129
+ f"Received on-demand request to render templates for {cid} ({request})"
130
+ )
131
+ job = rendering.RenderJob(
132
+ id=cid, request=request, context=ctx.get_context(request)
133
+ )
134
+ _ = job.submit()
135
+ await ONDEMAND.task_done(cid)
136
+
137
+
138
+ # noinspection PyProtectedMember
139
+ async def monitor_render_queue():
140
+ """Periodically report render queue size metrics"""
141
+ while True:
142
+ await asyncio.sleep(10)
143
+ stats.gauge("template.on_demand_queue_size", ONDEMAND._queue.qsize())
144
+
145
+
146
+ @asynccontextmanager
147
+ async def lifespan(_: FastAPI):
148
+ # Template Rendering
149
+ log.debug("Starting rendering loops")
150
+ asyncio.create_task(render_on_event(template_context))
151
+ asyncio.create_task(render_on_demand(template_context))
152
+ asyncio.create_task(monitor_render_queue())
153
+
154
+ # Template context
155
+ subscription = bus.subscribe(Topic.CONTEXT)
156
+ log.debug("Starting context loop")
157
+ asyncio.create_task(template_context.start())
158
+ event = await subscription.get()
159
+ log.debug(event.message)
160
+
161
+ log.debug("Worker lifespan initialized")
162
+ yield
163
+
164
+
165
+ worker = FastAPI(lifespan=lifespan)
166
+ if dsn := config.sentry_dsn.get_secret_value():
167
+ try:
168
+ # noinspection PyUnusedImports
169
+ import sentry_sdk
170
+
171
+ # noinspection PyUnusedImports
172
+ from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
173
+
174
+ sentry_sdk.init(dsn)
175
+ worker.add_middleware(SentryAsgiMiddleware) # type: ignore
176
+ except ImportError: # pragma: no cover
177
+ log.error("Sentry DSN configured but failed to attach to worker")
178
+
179
+
180
+ @worker.get("/health")
181
+ def health():
182
+ return "OK"
183
+
184
+
185
+ @worker.put("/client")
186
+ async def client_add(
187
+ registration: RegisterClientRequest = Body(...),
188
+ ):
189
+ log.info(f"Received registration: {registration.request}")
190
+ xds = registration.request
191
+ client_id, req = writer.register(xds)
192
+ ONDEMAND.put_nowait((client_id, req))
193
+ return "Registered", 200