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.
- sovereign/__init__.py +13 -81
- sovereign/app.py +59 -48
- sovereign/cache/__init__.py +172 -0
- sovereign/cache/backends/__init__.py +110 -0
- sovereign/cache/backends/s3.py +143 -0
- sovereign/cache/filesystem.py +73 -0
- sovereign/cache/types.py +15 -0
- sovereign/configuration.py +573 -0
- sovereign/constants.py +1 -0
- sovereign/context.py +271 -104
- sovereign/dynamic_config/__init__.py +113 -0
- sovereign/dynamic_config/deser.py +78 -0
- sovereign/dynamic_config/loaders.py +120 -0
- sovereign/events.py +49 -0
- sovereign/logging/access_logger.py +85 -0
- sovereign/logging/application_logger.py +54 -0
- sovereign/logging/base_logger.py +41 -0
- sovereign/logging/bootstrapper.py +36 -0
- sovereign/logging/types.py +10 -0
- sovereign/middlewares.py +8 -7
- sovereign/modifiers/lib.py +1 -0
- sovereign/rendering.py +192 -0
- sovereign/response_class.py +18 -0
- sovereign/server.py +93 -35
- sovereign/sources/file.py +1 -1
- sovereign/sources/inline.py +1 -0
- sovereign/sources/lib.py +1 -0
- sovereign/sources/poller.py +296 -53
- sovereign/statistics.py +17 -20
- sovereign/templates/base.html +59 -46
- sovereign/templates/resources.html +203 -102
- sovereign/testing/loaders.py +8 -0
- sovereign/{modifiers/test.py → testing/modifiers.py} +0 -2
- sovereign/tracing.py +102 -0
- sovereign/types.py +299 -0
- sovereign/utils/auth.py +26 -13
- sovereign/utils/crypto/__init__.py +0 -0
- sovereign/utils/crypto/crypto.py +135 -0
- sovereign/utils/crypto/suites/__init__.py +21 -0
- sovereign/utils/crypto/suites/aes_gcm_cipher.py +42 -0
- sovereign/utils/crypto/suites/base_cipher.py +21 -0
- sovereign/utils/crypto/suites/disabled_cipher.py +25 -0
- sovereign/utils/crypto/suites/fernet_cipher.py +29 -0
- sovereign/utils/dictupdate.py +2 -1
- sovereign/utils/eds.py +37 -21
- sovereign/utils/mock.py +54 -16
- sovereign/utils/resources.py +17 -0
- sovereign/utils/version_info.py +8 -0
- sovereign/views/__init__.py +4 -0
- sovereign/views/api.py +61 -0
- sovereign/views/crypto.py +46 -15
- sovereign/views/discovery.py +37 -116
- sovereign/views/healthchecks.py +87 -18
- sovereign/views/interface.py +112 -112
- sovereign/worker.py +204 -0
- {sovereign-0.19.3.dist-info → sovereign-1.0.0b148.dist-info}/METADATA +79 -76
- sovereign-1.0.0b148.dist-info/RECORD +77 -0
- {sovereign-0.19.3.dist-info → sovereign-1.0.0b148.dist-info}/WHEEL +1 -1
- sovereign-1.0.0b148.dist-info/entry_points.txt +38 -0
- sovereign_files/__init__.py +0 -0
- sovereign_files/static/darkmode.js +51 -0
- sovereign_files/static/node_expression.js +42 -0
- sovereign_files/static/panel.js +76 -0
- sovereign_files/static/resources.css +246 -0
- sovereign_files/static/resources.js +642 -0
- sovereign_files/static/sass/style.scss +33 -0
- sovereign_files/static/style.css +16143 -0
- sovereign_files/static/style.css.map +1 -0
- sovereign/config_loader.py +0 -225
- sovereign/discovery.py +0 -175
- sovereign/logs.py +0 -131
- sovereign/schemas.py +0 -780
- sovereign/static/sass/style.scss +0 -27
- sovereign/static/style.css +0 -13553
- sovereign/templates/ul_filter.html +0 -22
- sovereign/utils/crypto.py +0 -103
- sovereign/views/admin.py +0 -120
- sovereign-0.19.3.dist-info/LICENSE.txt +0 -13
- sovereign-0.19.3.dist-info/RECORD +0 -47
- sovereign-0.19.3.dist-info/entry_points.txt +0 -10
sovereign/views/healthchecks.py
CHANGED
|
@@ -1,38 +1,107 @@
|
|
|
1
|
-
|
|
2
|
-
from
|
|
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
|
-
|
|
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.
|
|
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
|
|
21
|
-
response_class=json_response_class,
|
|
64
|
+
summary="Deepcheck (Can the server render all default templates?)",
|
|
22
65
|
)
|
|
23
|
-
async def deep_check(
|
|
24
|
-
|
|
25
|
-
|
|
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(
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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")
|
sovereign/views/interface.py
CHANGED
|
@@ -1,77 +1,56 @@
|
|
|
1
|
-
|
|
1
|
+
import json
|
|
2
2
|
from collections import defaultdict
|
|
3
|
-
from
|
|
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
|
|
7
|
-
from
|
|
8
|
-
|
|
9
|
-
from sovereign import
|
|
10
|
-
from sovereign.
|
|
11
|
-
from sovereign.
|
|
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
|
|
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
|
-
|
|
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":
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
86
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
"
|
|
120
|
-
"
|
|
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
|
-
|
|
138
|
-
"
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
170
|
-
"
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|