sovereign 0.19.3__py3-none-any.whl → 1.0.0b148__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 (80) hide show
  1. sovereign/__init__.py +13 -81
  2. sovereign/app.py +59 -48
  3. sovereign/cache/__init__.py +172 -0
  4. sovereign/cache/backends/__init__.py +110 -0
  5. sovereign/cache/backends/s3.py +143 -0
  6. sovereign/cache/filesystem.py +73 -0
  7. sovereign/cache/types.py +15 -0
  8. sovereign/configuration.py +573 -0
  9. sovereign/constants.py +1 -0
  10. sovereign/context.py +271 -104
  11. sovereign/dynamic_config/__init__.py +113 -0
  12. sovereign/dynamic_config/deser.py +78 -0
  13. sovereign/dynamic_config/loaders.py +120 -0
  14. sovereign/events.py +49 -0
  15. sovereign/logging/access_logger.py +85 -0
  16. sovereign/logging/application_logger.py +54 -0
  17. sovereign/logging/base_logger.py +41 -0
  18. sovereign/logging/bootstrapper.py +36 -0
  19. sovereign/logging/types.py +10 -0
  20. sovereign/middlewares.py +8 -7
  21. sovereign/modifiers/lib.py +1 -0
  22. sovereign/rendering.py +192 -0
  23. sovereign/response_class.py +18 -0
  24. sovereign/server.py +93 -35
  25. sovereign/sources/file.py +1 -1
  26. sovereign/sources/inline.py +1 -0
  27. sovereign/sources/lib.py +1 -0
  28. sovereign/sources/poller.py +296 -53
  29. sovereign/statistics.py +17 -20
  30. sovereign/templates/base.html +59 -46
  31. sovereign/templates/resources.html +203 -102
  32. sovereign/testing/loaders.py +8 -0
  33. sovereign/{modifiers/test.py → testing/modifiers.py} +0 -2
  34. sovereign/tracing.py +102 -0
  35. sovereign/types.py +299 -0
  36. sovereign/utils/auth.py +26 -13
  37. sovereign/utils/crypto/__init__.py +0 -0
  38. sovereign/utils/crypto/crypto.py +135 -0
  39. sovereign/utils/crypto/suites/__init__.py +21 -0
  40. sovereign/utils/crypto/suites/aes_gcm_cipher.py +42 -0
  41. sovereign/utils/crypto/suites/base_cipher.py +21 -0
  42. sovereign/utils/crypto/suites/disabled_cipher.py +25 -0
  43. sovereign/utils/crypto/suites/fernet_cipher.py +29 -0
  44. sovereign/utils/dictupdate.py +2 -1
  45. sovereign/utils/eds.py +37 -21
  46. sovereign/utils/mock.py +54 -16
  47. sovereign/utils/resources.py +17 -0
  48. sovereign/utils/version_info.py +8 -0
  49. sovereign/views/__init__.py +4 -0
  50. sovereign/views/api.py +61 -0
  51. sovereign/views/crypto.py +46 -15
  52. sovereign/views/discovery.py +37 -116
  53. sovereign/views/healthchecks.py +87 -18
  54. sovereign/views/interface.py +112 -112
  55. sovereign/worker.py +204 -0
  56. {sovereign-0.19.3.dist-info → sovereign-1.0.0b148.dist-info}/METADATA +79 -76
  57. sovereign-1.0.0b148.dist-info/RECORD +77 -0
  58. {sovereign-0.19.3.dist-info → sovereign-1.0.0b148.dist-info}/WHEEL +1 -1
  59. sovereign-1.0.0b148.dist-info/entry_points.txt +38 -0
  60. sovereign_files/__init__.py +0 -0
  61. sovereign_files/static/darkmode.js +51 -0
  62. sovereign_files/static/node_expression.js +42 -0
  63. sovereign_files/static/panel.js +76 -0
  64. sovereign_files/static/resources.css +246 -0
  65. sovereign_files/static/resources.js +642 -0
  66. sovereign_files/static/sass/style.scss +33 -0
  67. sovereign_files/static/style.css +16143 -0
  68. sovereign_files/static/style.css.map +1 -0
  69. sovereign/config_loader.py +0 -225
  70. sovereign/discovery.py +0 -175
  71. sovereign/logs.py +0 -131
  72. sovereign/schemas.py +0 -780
  73. sovereign/static/sass/style.scss +0 -27
  74. sovereign/static/style.css +0 -13553
  75. sovereign/templates/ul_filter.html +0 -22
  76. sovereign/utils/crypto.py +0 -103
  77. sovereign/views/admin.py +0 -120
  78. sovereign-0.19.3.dist-info/LICENSE.txt +0 -13
  79. sovereign-0.19.3.dist-info/RECORD +0 -47
  80. sovereign-0.19.3.dist-info/entry_points.txt +0 -10
@@ -1,38 +1,107 @@
1
- from typing import List
2
- from fastapi import Response
1
+ import asyncio
2
+ from typing_extensions import Annotated, Literal
3
+
4
+ import requests
5
+ import pydantic
6
+ from fastapi import Request, Response, Query
3
7
  from fastapi.routing import APIRouter
4
- from fastapi.responses import PlainTextResponse
5
- from sovereign import XDS_TEMPLATES, __version__, json_response_class
8
+ from fastapi.responses import JSONResponse, PlainTextResponse
9
+
10
+ from sovereign import __version__
11
+ from sovereign.views import reader
12
+ from sovereign.configuration import XDS_TEMPLATES
6
13
  from sovereign.utils.mock import mock_discovery_request
7
- from sovereign.views.discovery import perform_discovery
14
+ from sovereign.response_class import json_response_class
8
15
 
9
16
 
10
17
  router = APIRouter()
11
18
 
19
+ State = Literal["FAIL"] | Literal["OK"]
20
+ Message = str
21
+ CheckResult = tuple[State, Message] | State
22
+
23
+
24
+ class DeepCheckResult(pydantic.BaseModel):
25
+ templates: dict[str, CheckResult] = pydantic.Field(default_factory=dict)
26
+ worker: CheckResult = pydantic.Field(default=("FAIL", "Worker unavailable"))
27
+
28
+ def response(self) -> PlainTextResponse:
29
+ return PlainTextResponse(content=self.message, status_code=self.status)
30
+
31
+ def json_response(self) -> JSONResponse:
32
+ return json_response_class(content=self.model_dump(), status_code=self.status)
33
+
34
+ @property
35
+ def message(self) -> str:
36
+ msg = "Templates:\n"
37
+ for template, result in sorted(self.templates.items()):
38
+ msg += f"* {template} {result}\n"
39
+ msg += f"Worker: {self.worker}\n"
40
+ return msg
41
+
42
+ @property
43
+ def status(self) -> int:
44
+ if self.is_err():
45
+ return 500
46
+ return 200
47
+
48
+ def is_err(self):
49
+ for result in self.templates.values():
50
+ if result[0] == "FAIL":
51
+ return True
52
+ if self.worker[0] == "FAIL":
53
+ return True
54
+ return False
55
+
12
56
 
13
57
  @router.get("/healthcheck", summary="Healthcheck (Does the server respond to HTTP?)")
14
58
  async def health_check() -> Response:
15
- return PlainTextResponse("OK")
59
+ return PlainTextResponse("OK", status_code=200)
16
60
 
17
61
 
18
62
  @router.get(
19
63
  "/deepcheck",
20
- summary="Deepcheck (Can the server render all configured templates?)",
21
- response_class=json_response_class,
64
+ summary="Deepcheck (Can the server render all default templates?)",
22
65
  )
23
- async def deep_check(response: Response) -> List[str]:
24
- response.status_code = 200
25
- ret = list()
66
+ async def deep_check(
67
+ request: Request,
68
+ worker_attempts: Annotated[
69
+ int,
70
+ Query(
71
+ description="How many times to try to contact the worker before giving up",
72
+ ),
73
+ ] = 5,
74
+ envoy_service_cluster: Annotated[
75
+ str,
76
+ Query(
77
+ description="Which service cluster to use when checking if a template can be rendered",
78
+ ),
79
+ ] = "*",
80
+ ) -> Response:
81
+ result = DeepCheckResult()
26
82
  for template in list(XDS_TEMPLATES["default"].keys()):
27
83
  try:
28
- req = mock_discovery_request(service_cluster="*")
29
- await perform_discovery(req, "v3", resource_type=template, skip_auth=True)
30
- # pylint: disable=broad-except
84
+ req = mock_discovery_request(
85
+ "v3",
86
+ template,
87
+ expressions=[f"cluster={envoy_service_cluster}"],
88
+ )
89
+ _ = await reader.blocking_read(req)
90
+ result.templates[template] = "OK"
91
+ except Exception as e:
92
+ result.templates[template] = ("FAIL", f"Failed {template}: {str(e)}")
93
+ for attempt in range(worker_attempts):
94
+ try:
95
+ worker_health = requests.get("http://localhost:9080/health")
96
+ if worker_health.ok:
97
+ result.worker = "OK"
98
+ break
31
99
  except Exception as e:
32
- ret.append(f"Failed {template}: {str(e)}")
33
- else:
34
- ret.append(f"Rendered {template} OK")
35
- return ret
100
+ result.worker = ("FAIL", str(e))
101
+ await asyncio.sleep(attempt)
102
+ if "json" in request.headers.get("Accept", ""):
103
+ return result.json_response()
104
+ return result.response()
36
105
 
37
106
 
38
107
  @router.get("/version", summary="Display the current version of Sovereign")
@@ -1,77 +1,56 @@
1
- from typing import List, Dict, Any
1
+ import json
2
2
  from collections import defaultdict
3
- from fastapi import APIRouter, Query, Path, Cookie
3
+ from typing import Any, Dict, List
4
+
5
+ from fastapi import APIRouter, Cookie, Path, Query
4
6
  from fastapi.encoders import jsonable_encoder
5
7
  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
8
+ from fastapi.responses import HTMLResponse, JSONResponse, Response
9
+ from starlette.templating import Jinja2Templates
10
+
11
+ from sovereign import __version__
12
+ from sovereign.views import reader
13
+ from sovereign.configuration import ConfiguredResourceTypes, XDS_TEMPLATES
14
+ from sovereign.response_class import json_response_class
15
+ from sovereign.utils.mock import NodeExpressionError, mock_discovery_request
16
+ from sovereign.utils.resources import get_package_file
12
17
 
13
18
  router = APIRouter()
14
19
 
15
- all_types = [t.value for t in DiscoveryTypes]
20
+ all_types = [t.value for t in ConfiguredResourceTypes]
21
+
22
+ html_templates = Jinja2Templates(
23
+ directory=str(get_package_file("sovereign", "templates"))
24
+ )
16
25
 
17
26
 
18
27
  @router.get("/")
19
- async def ui_main(request: Request) -> Response:
28
+ @router.get("/resources")
29
+ async def ui_main(request: Request) -> HTMLResponse:
20
30
  try:
21
31
  return html_templates.TemplateResponse(
32
+ request=request,
22
33
  name="base.html",
23
34
  media_type="text/html",
24
- context={
25
- "request": request,
26
- "all_types": all_types,
27
- "last_update": str(poller.last_updated),
28
- },
35
+ context={"all_types": all_types, "sovereign_version": __version__},
29
36
  )
30
37
  except IndexError:
31
38
  return html_templates.TemplateResponse(
39
+ request=request,
32
40
  name="err.html",
33
41
  media_type="text/html",
34
42
  context={
35
- "request": request,
36
43
  "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/",
44
+ "message": (
45
+ "A template should be defined for every resource "
46
+ "type that you want your envoy proxies to discover."
47
+ ),
48
+ "doc_link": "https://developer.atlassian.com/platform/sovereign/tutorial/templates/#templates",
49
+ "sovereign_version": __version__,
40
50
  },
41
51
  )
42
52
 
43
53
 
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
-
75
54
  @router.get(
76
55
  "/resources/{xds_type}", summary="List available resources for a given xDS type"
77
56
  )
@@ -82,45 +61,60 @@ async def resources(
82
61
  None, title="The clients region to emulate in this XDS request"
83
62
  ),
84
63
  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"
64
+ node_expression: str = Cookie(
65
+ "cluster=*", title="Node expression to filter resources with"
87
66
  ),
88
67
  envoy_version: str = Cookie(
89
68
  "__any__", title="The clients envoy version to emulate in this XDS request"
90
69
  ),
91
- ) -> Response:
70
+ debug: int = Query(0, title="Show debug information on errors"),
71
+ ) -> HTMLResponse:
92
72
  ret: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
73
+ response = None
93
74
  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,
75
+ mock_request = mock_discovery_request(
76
+ api_version,
77
+ xds_type,
78
+ version=envoy_version,
79
+ region=region,
80
+ expressions=node_expression.split(),
81
+ )
82
+ clear_cookie = False
83
+ error = None
84
+ except NodeExpressionError as e:
85
+ mock_request = mock_discovery_request(
86
+ api_version,
87
+ xds_type,
88
+ version=envoy_version,
89
+ region=region,
104
90
  )
105
- except KeyError:
106
- ret["resources"] = []
107
- else:
108
- ret["resources"] += response.deserialize_resources()
109
- return html_templates.TemplateResponse(
91
+ clear_cookie = True
92
+ error = str(e)
93
+
94
+ response = await reader.blocking_read(mock_request)
95
+ if response:
96
+ ret["resources"] = json.loads(response.text).get("resources", [])
97
+
98
+ resp = html_templates.TemplateResponse(
99
+ request=request,
110
100
  name="resources.html",
111
101
  media_type="text/html",
112
102
  context={
103
+ "show_debuginfo": True if debug else False,
104
+ "discovery_response": response,
105
+ "discovery_request": mock_request,
113
106
  "resources": ret["resources"],
114
- "request": request,
115
107
  "resource_type": xds_type,
116
108
  "all_types": all_types,
117
109
  "version": envoy_version,
118
110
  "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),
111
+ "error": error,
112
+ "sovereign_version": __version__,
122
113
  },
123
114
  )
115
+ if clear_cookie:
116
+ resp.delete_cookie("node_expression", path="/ui/resources/")
117
+ return resp
124
118
 
125
119
 
126
120
  @router.get(
@@ -134,25 +128,32 @@ async def resource(
134
128
  None, title="The clients region to emulate in this XDS request"
135
129
  ),
136
130
  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"
131
+ node_expression: str = Cookie(
132
+ "cluster=*", title="Node expression to filter resources with"
139
133
  ),
140
134
  envoy_version: str = Cookie(
141
135
  "__any__", title="The clients envoy version to emulate in this XDS request"
142
136
  ),
143
137
  ) -> 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,
138
+ mock_request = mock_discovery_request(
139
+ api_version,
140
+ xds_type,
141
+ version=envoy_version,
142
+ region=region,
143
+ expressions=node_expression.split(),
144
+ )
145
+ if response := await reader.blocking_read(mock_request):
146
+ for res in json.loads(response.text).get("resources", []):
147
+ if res.get("name", res.get("cluster_name")) == resource_name:
148
+ safe_response = jsonable_encoder(res)
149
+ try:
150
+ return json_response_class(content=safe_response)
151
+ except TypeError:
152
+ return JSONResponse(content=safe_response)
153
+ return Response(
154
+ json.dumps({"title": "No resources found", "status": 404}),
155
+ media_type="application/json+problem",
154
156
  )
155
- return Response(response.rendered, media_type="application/json")
156
157
 
157
158
 
158
159
  @router.get(
@@ -166,36 +167,35 @@ async def virtual_hosts(
166
167
  None, title="The clients region to emulate in this XDS request"
167
168
  ),
168
169
  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"
170
+ node_expression: str = Cookie(
171
+ "cluster=*", title="Node expression to filter resources with"
171
172
  ),
172
173
  envoy_version: str = Cookie(
173
174
  "__any__", title="The clients envoy version to emulate in this XDS request"
174
175
  ),
175
176
  ) -> 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,
177
+ mock_request = mock_discovery_request(
178
+ api_version,
179
+ "routes",
180
+ version=envoy_version,
181
+ region=region,
182
+ expressions=node_expression.split(),
183
+ )
184
+ if response := await reader.blocking_read(mock_request):
185
+ route_configs = [
186
+ resource_
187
+ for resource_ in json.loads(response.text).get("resources", [])
188
+ if resource_["name"] == route_configuration
189
+ ]
190
+ for route_config in route_configs:
191
+ for vhost in route_config["virtual_hosts"]:
192
+ if vhost["name"] == virtual_host:
193
+ safe_response = jsonable_encoder(vhost)
194
+ try:
195
+ return json_response_class(content=safe_response)
196
+ except TypeError:
197
+ return JSONResponse(content=safe_response)
198
+ return Response(
199
+ json.dumps({"title": "No resources found", "status": 404}),
200
+ media_type="application/json+problem",
186
201
  )
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,204 @@
1
+ import asyncio
2
+ from typing import final
3
+ from contextlib import asynccontextmanager
4
+
5
+ from fastapi import FastAPI, Body
6
+
7
+ from sovereign import (
8
+ cache,
9
+ rendering,
10
+ server_cipher_container,
11
+ disabled_ciphersuite,
12
+ application_logger as log,
13
+ stats,
14
+ )
15
+ from sovereign.context import TemplateContext
16
+ from sovereign.sources import SourcePoller
17
+ from sovereign.configuration import config
18
+ from sovereign.types import RegisterClientRequest, DiscoveryRequest
19
+ from sovereign.events import bus, Topic
20
+
21
+
22
+ def hidden_field(*args, **kwargs):
23
+ return "(value hidden)"
24
+
25
+
26
+ def inject_builtin_items(request, output):
27
+ output["__hide_from_ui"] = lambda v: v
28
+ output["crypto"] = server_cipher_container
29
+ if request.is_internal_request:
30
+ output["__hide_from_ui"] = hidden_field
31
+ output["crypto"] = disabled_ciphersuite
32
+
33
+
34
+ template_context = TemplateContext.from_config()
35
+ context_middleware = [inject_builtin_items]
36
+ template_context.middleware = context_middleware
37
+ writer = cache.CacheWriter()
38
+
39
+ ClientId = str
40
+ OnDemandJob = tuple[ClientId, DiscoveryRequest]
41
+
42
+
43
+ @final
44
+ class RenderQueue:
45
+ def __init__(self, maxsize: int = 0):
46
+ self._queue: asyncio.Queue[OnDemandJob] = asyncio.Queue(maxsize)
47
+ self._set: set[ClientId] = set()
48
+ self._lock = asyncio.Lock()
49
+
50
+ async def put(self, item: OnDemandJob):
51
+ cid = item[0]
52
+ async with self._lock:
53
+ if cid not in self._set:
54
+ await self._queue.put(item)
55
+ self._set.add(cid)
56
+
57
+ def put_nowait(self, item: OnDemandJob):
58
+ cid = item[0]
59
+ if cid in self._set:
60
+ return
61
+ if self._queue.full():
62
+ raise asyncio.QueueFull
63
+ self._queue.put_nowait(item)
64
+ self._set.add(cid)
65
+
66
+ async def get(self):
67
+ return await self._queue.get()
68
+
69
+ def full(self):
70
+ return self._queue.full()
71
+
72
+ async def task_done(self, cid):
73
+ async with self._lock:
74
+ self._set.remove(cid)
75
+ self._queue.task_done()
76
+
77
+
78
+ ONDEMAND = RenderQueue()
79
+
80
+
81
+ poller = None
82
+ if config.sources is not None:
83
+ if config.matching is not None:
84
+ matching_enabled = config.matching.enabled
85
+ node_key: str | None = config.matching.node_key
86
+ source_key: str | None = config.matching.source_key
87
+ else:
88
+ matching_enabled = False
89
+ node_key = None
90
+ source_key = None
91
+ poller = SourcePoller(
92
+ sources=config.sources,
93
+ matching_enabled=matching_enabled,
94
+ node_match_key=node_key,
95
+ source_match_key=source_key,
96
+ source_refresh_rate=config.source_config.refresh_rate,
97
+ logger=log,
98
+ stats=stats,
99
+ )
100
+ context_middleware.append(poller.add_to_context)
101
+
102
+
103
+ async def render_on_event(ctx):
104
+ subscription = bus.subscribe(Topic.CONTEXT)
105
+ while True:
106
+ # block forever until new context arrives
107
+ event = await subscription.get()
108
+ context_name = event.metadata.get("name")
109
+
110
+ log.debug(event.message)
111
+ try:
112
+ if registered := writer.get_registered_clients():
113
+ size = len(registered)
114
+ stats.increment("template.render_on_event", tags=[f"batch_size:{size}"])
115
+
116
+ for client, request in registered:
117
+ if context_name in request.template.depends_on:
118
+ log.info(
119
+ f"Rendering template on-event for {request} because {context_name} was updated"
120
+ )
121
+ job = rendering.RenderJob(
122
+ id=client,
123
+ request=request,
124
+ context=ctx.get_context(request),
125
+ )
126
+ job.submit()
127
+
128
+ finally:
129
+ await asyncio.sleep(config.template_context.cooldown)
130
+
131
+
132
+ async def render_on_demand(ctx):
133
+ while True:
134
+ id, request = await ONDEMAND.get()
135
+ stats.increment("template.render_on_demand")
136
+ log.debug(
137
+ f"Received on-demand request to render templates for {id} ({request})"
138
+ )
139
+ job = rendering.RenderJob(
140
+ id=id, request=request, context=ctx.get_context(request)
141
+ )
142
+ _ = job.submit()
143
+ await ONDEMAND.task_done(id)
144
+
145
+
146
+ async def monitor_render_queue():
147
+ """Periodically report render queue size metrics"""
148
+ while True:
149
+ await asyncio.sleep(10)
150
+ stats.gauge("template.on_demand_queue_size", ONDEMAND._queue.qsize())
151
+
152
+
153
+ @asynccontextmanager
154
+ async def lifespan(_: FastAPI):
155
+ # Template Rendering
156
+ log.debug("Starting rendering loops")
157
+ asyncio.create_task(render_on_event(template_context))
158
+ asyncio.create_task(render_on_demand(template_context))
159
+ asyncio.create_task(monitor_render_queue())
160
+
161
+ # Template context
162
+ subscription = bus.subscribe(Topic.CONTEXT)
163
+ log.debug("Starting context loop")
164
+ asyncio.create_task(template_context.start())
165
+ event = await subscription.get()
166
+ log.debug(event.message)
167
+
168
+ # Source polling
169
+ if poller is not None:
170
+ log.debug("Starting source poller")
171
+ poller.lazy_load_modifiers(config.modifiers)
172
+ poller.lazy_load_global_modifiers(config.global_modifiers)
173
+ asyncio.create_task(poller.poll_forever())
174
+
175
+ log.debug("Worker lifespan initialized")
176
+ yield
177
+
178
+
179
+ worker = FastAPI(lifespan=lifespan)
180
+ if dsn := config.sentry_dsn.get_secret_value():
181
+ try:
182
+ import sentry_sdk
183
+ from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
184
+
185
+ sentry_sdk.init(dsn)
186
+ worker.add_middleware(SentryAsgiMiddleware) # type: ignore
187
+ except ImportError: # pragma: no cover
188
+ log.error("Sentry DSN configured but failed to attach to worker")
189
+
190
+
191
+ @worker.get("/health")
192
+ def health():
193
+ return "OK"
194
+
195
+
196
+ @worker.put("/client")
197
+ async def client_add(
198
+ registration: RegisterClientRequest = Body(...),
199
+ ):
200
+ log.info(f"Received registration: {registration.request}")
201
+ xds = registration.request
202
+ id, req = writer.register(xds)
203
+ ONDEMAND.put_nowait((id, req))
204
+ return "Registered", 200