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/__init__.py
CHANGED
|
@@ -1,50 +1,11 @@
|
|
|
1
|
-
import os
|
|
2
1
|
from contextvars import ContextVar
|
|
3
|
-
from typing import Type, Any, Mapping
|
|
4
2
|
from importlib.metadata import version
|
|
5
|
-
from pkg_resources import resource_filename
|
|
6
3
|
|
|
7
|
-
from
|
|
8
|
-
from
|
|
9
|
-
from
|
|
10
|
-
|
|
11
|
-
from sovereign.schemas import (
|
|
12
|
-
SovereignAsgiConfig,
|
|
13
|
-
SovereignConfig,
|
|
14
|
-
SovereignConfigv2,
|
|
15
|
-
)
|
|
16
|
-
from sovereign import config_loader
|
|
17
|
-
from sovereign.logs import LoggerBootstrapper
|
|
4
|
+
from sovereign.utils.crypto.suites import EncryptionType
|
|
5
|
+
from sovereign.logging.bootstrapper import LoggerBootstrapper
|
|
6
|
+
from sovereign.configuration import config, EncryptionConfig
|
|
18
7
|
from sovereign.statistics import configure_statsd
|
|
19
|
-
from sovereign.utils.
|
|
20
|
-
from sovereign.sources import SourcePoller
|
|
21
|
-
from sovereign.context import TemplateContext
|
|
22
|
-
from sovereign.utils.crypto import CipherContainer, create_cipher_suite
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
json_response_class: Type[JSONResponse] = JSONResponse
|
|
26
|
-
try:
|
|
27
|
-
import orjson
|
|
28
|
-
from fastapi.responses import ORJSONResponse
|
|
29
|
-
|
|
30
|
-
json_response_class = ORJSONResponse
|
|
31
|
-
except ImportError:
|
|
32
|
-
try:
|
|
33
|
-
import ujson
|
|
34
|
-
from fastapi.responses import UJSONResponse
|
|
35
|
-
|
|
36
|
-
json_response_class = UJSONResponse
|
|
37
|
-
except ImportError:
|
|
38
|
-
pass
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def parse_raw_configuration(path: str) -> Mapping[Any, Any]:
|
|
42
|
-
ret: Mapping[Any, Any] = dict()
|
|
43
|
-
for p in path.split(","):
|
|
44
|
-
spec = config_loader.Loadable.from_legacy_fmt(p)
|
|
45
|
-
ret = merge(obj_a=ret, obj_b=spec.load(), merge_lists=True)
|
|
46
|
-
return ret
|
|
47
|
-
|
|
8
|
+
from sovereign.utils.crypto.crypto import CipherContainer
|
|
48
9
|
|
|
49
10
|
_request_id_ctx_var: ContextVar[str] = ContextVar("request_id", default="")
|
|
50
11
|
|
|
@@ -53,48 +14,19 @@ def get_request_id() -> str:
|
|
|
53
14
|
return _request_id_ctx_var.get()
|
|
54
15
|
|
|
55
16
|
|
|
17
|
+
WORKER_URL = "http://localhost:9080"
|
|
56
18
|
DIST_NAME = "sovereign"
|
|
57
|
-
|
|
58
19
|
__version__ = version(DIST_NAME)
|
|
59
|
-
config_path = os.getenv("SOVEREIGN_CONFIG", "file:///etc/sovereign.yaml")
|
|
60
|
-
|
|
61
|
-
html_templates = Jinja2Templates(resource_filename(DIST_NAME, "templates"))
|
|
62
|
-
|
|
63
|
-
try:
|
|
64
|
-
config = SovereignConfigv2(**parse_raw_configuration(config_path))
|
|
65
|
-
except ValidationError:
|
|
66
|
-
old_config = SovereignConfig(**parse_raw_configuration(config_path))
|
|
67
|
-
config = SovereignConfigv2.from_legacy_config(old_config)
|
|
68
|
-
asgi_config = SovereignAsgiConfig()
|
|
69
|
-
XDS_TEMPLATES = config.xds_templates()
|
|
70
20
|
|
|
21
|
+
stats = configure_statsd()
|
|
71
22
|
logs = LoggerBootstrapper(config)
|
|
72
|
-
|
|
73
|
-
poller = SourcePoller(
|
|
74
|
-
sources=config.sources,
|
|
75
|
-
matching_enabled=config.matching.enabled,
|
|
76
|
-
node_match_key=config.matching.node_key,
|
|
77
|
-
source_match_key=config.matching.source_key,
|
|
78
|
-
source_refresh_rate=config.source_config.refresh_rate,
|
|
79
|
-
logger=logs.application_log,
|
|
80
|
-
stats=stats,
|
|
81
|
-
)
|
|
23
|
+
application_logger = logs.application_logger.logger
|
|
82
24
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
[create_cipher_suite(key=key, logger=logs) for key in encryption_keys]
|
|
25
|
+
encryption_configs = config.authentication.encryption_configs
|
|
26
|
+
server_cipher_container = CipherContainer.from_encryption_configs(
|
|
27
|
+
encryption_configs, logger=application_logger
|
|
87
28
|
)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
refresh_cron=config.template_context.refresh_cron,
|
|
92
|
-
configured_context=config.template_context.context,
|
|
93
|
-
poller=poller,
|
|
94
|
-
encryption_suite=cipher_suite,
|
|
95
|
-
disabled_suite=create_cipher_suite(b"", logs),
|
|
96
|
-
logger=logs.application_log,
|
|
97
|
-
stats=stats,
|
|
29
|
+
disabled_ciphersuite = CipherContainer.from_encryption_configs(
|
|
30
|
+
encryption_configs=[EncryptionConfig("", EncryptionType.DISABLED)],
|
|
31
|
+
logger=application_logger,
|
|
98
32
|
)
|
|
99
|
-
poller.lazy_load_modifiers(config.modifiers)
|
|
100
|
-
poller.lazy_load_global_modifiers(config.global_modifiers)
|
sovereign/app.py
CHANGED
|
@@ -1,38 +1,22 @@
|
|
|
1
|
-
import asyncio
|
|
2
1
|
import traceback
|
|
3
|
-
import uvicorn
|
|
4
2
|
from collections import namedtuple
|
|
3
|
+
|
|
4
|
+
import uvicorn
|
|
5
5
|
from fastapi import FastAPI, Request
|
|
6
|
-
from fastapi.responses import
|
|
7
|
-
from
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
json_response_class,
|
|
13
|
-
poller,
|
|
14
|
-
template_context,
|
|
15
|
-
logs,
|
|
16
|
-
)
|
|
6
|
+
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
|
|
7
|
+
from starlette_context.middleware import RawContextMiddleware
|
|
8
|
+
|
|
9
|
+
from sovereign import __version__, logs
|
|
10
|
+
from sovereign.configuration import config, ConfiguredResourceTypes
|
|
11
|
+
from sovereign.response_class import json_response_class
|
|
17
12
|
from sovereign.error_info import ErrorInfo
|
|
18
|
-
from sovereign.
|
|
19
|
-
from sovereign.
|
|
20
|
-
|
|
21
|
-
LoggingMiddleware,
|
|
22
|
-
)
|
|
13
|
+
from sovereign.middlewares import LoggingMiddleware, RequestContextLogMiddleware
|
|
14
|
+
from sovereign.utils.resources import get_package_file
|
|
15
|
+
from sovereign.views import crypto, discovery, healthchecks, interface, api
|
|
23
16
|
|
|
24
17
|
Router = namedtuple("Router", "module tags prefix")
|
|
25
18
|
|
|
26
19
|
DEBUG = config.debug
|
|
27
|
-
SENTRY_DSN = config.sentry_dsn.get_secret_value()
|
|
28
|
-
|
|
29
|
-
try:
|
|
30
|
-
import sentry_sdk
|
|
31
|
-
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
|
|
32
|
-
|
|
33
|
-
SENTRY_INSTALLED = True
|
|
34
|
-
except ImportError: # pragma: no cover
|
|
35
|
-
SENTRY_INSTALLED = False
|
|
36
20
|
|
|
37
21
|
|
|
38
22
|
def generic_error_response(e: Exception) -> JSONResponse:
|
|
@@ -46,7 +30,7 @@ def generic_error_response(e: Exception) -> JSONResponse:
|
|
|
46
30
|
"""
|
|
47
31
|
tb = [line for line in traceback.format_exc().split("\n")]
|
|
48
32
|
info = ErrorInfo.from_exception(e)
|
|
49
|
-
logs.queue_log_fields(
|
|
33
|
+
logs.access_logger.queue_log_fields(
|
|
50
34
|
ERROR=info.error,
|
|
51
35
|
ERROR_DETAIL=info.detail,
|
|
52
36
|
TRACEBACK=tb,
|
|
@@ -69,23 +53,32 @@ def init_app() -> FastAPI:
|
|
|
69
53
|
|
|
70
54
|
routers = (
|
|
71
55
|
Router(discovery.router, ["Configuration Discovery"], ""),
|
|
72
|
-
Router(
|
|
73
|
-
Router(admin.router, ["Debugging Endpoints"], "/admin"),
|
|
74
|
-
Router(interface.router, ["User Interface"], "/ui"),
|
|
56
|
+
Router(api.router, ["API"], "/api"),
|
|
75
57
|
Router(healthchecks.router, ["Healthchecks"], ""),
|
|
58
|
+
Router(interface.router, ["User Interface"], "/ui"),
|
|
59
|
+
Router(crypto.router, ["Cryptographic Utilities"], "/crypto"),
|
|
76
60
|
)
|
|
77
61
|
for router in routers:
|
|
78
62
|
application.include_router(
|
|
79
63
|
router.module, tags=router.tags, prefix=router.prefix
|
|
80
64
|
)
|
|
81
65
|
|
|
82
|
-
application.add_middleware(RequestContextLogMiddleware)
|
|
83
|
-
application.add_middleware(LoggingMiddleware)
|
|
66
|
+
application.add_middleware(RequestContextLogMiddleware) # type: ignore
|
|
67
|
+
application.add_middleware(LoggingMiddleware) # type: ignore
|
|
68
|
+
|
|
69
|
+
if dsn := config.sentry_dsn.get_secret_value():
|
|
70
|
+
try:
|
|
71
|
+
import sentry_sdk
|
|
72
|
+
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
|
|
84
73
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
74
|
+
sentry_sdk.init(dsn)
|
|
75
|
+
application.add_middleware(SentryAsgiMiddleware) # type: ignore
|
|
76
|
+
except ImportError:
|
|
77
|
+
logs.application_logger.logger.error(
|
|
78
|
+
"Sentry DSN configured but failed to attach to webserver"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
application.add_middleware(RawContextMiddleware) # type: ignore
|
|
89
82
|
|
|
90
83
|
@application.exception_handler(500)
|
|
91
84
|
async def exception_handler(_: Request, exc: Exception) -> JSONResponse:
|
|
@@ -103,23 +96,41 @@ def init_app() -> FastAPI:
|
|
|
103
96
|
|
|
104
97
|
@application.get("/static/{filename}", summary="Return a static asset")
|
|
105
98
|
async def static(filename: str) -> Response:
|
|
106
|
-
return FileResponse(
|
|
107
|
-
|
|
108
|
-
@application.on_event("startup")
|
|
109
|
-
async def keep_sources_uptodate() -> None:
|
|
110
|
-
asyncio.create_task(poller.poll_forever())
|
|
99
|
+
return FileResponse(get_package_file("sovereign_files", f"static/{filename}")) # type: ignore[arg-type]
|
|
111
100
|
|
|
112
|
-
@application.
|
|
113
|
-
|
|
114
|
-
|
|
101
|
+
@application.get(
|
|
102
|
+
"/admin/xds_dump",
|
|
103
|
+
summary="Deprecated API, please use /api/resources/{resource_type}",
|
|
104
|
+
)
|
|
105
|
+
async def dump_resources(request: Request) -> Response:
|
|
106
|
+
resource_type = ConfiguredResourceTypes(
|
|
107
|
+
request.query_params.get("xds_type", "cluster")
|
|
108
|
+
)
|
|
109
|
+
resource_name = request.query_params.get("name")
|
|
110
|
+
api_version = request.query_params.get("api_version", "v3")
|
|
111
|
+
service_cluster = request.query_params.get("service_cluster", "*")
|
|
112
|
+
region = request.query_params.get("region")
|
|
113
|
+
version = request.query_params.get("version")
|
|
114
|
+
response = await api.resource(
|
|
115
|
+
resource_type=resource_type,
|
|
116
|
+
resource_name=resource_name,
|
|
117
|
+
api_version=api_version,
|
|
118
|
+
service_cluster=service_cluster,
|
|
119
|
+
region=region,
|
|
120
|
+
version=version,
|
|
121
|
+
)
|
|
122
|
+
response.headers["Deprecation"] = "true"
|
|
123
|
+
response.headers["Link"] = f'</api/resources/{resource_type}>; rel="alternate"'
|
|
124
|
+
response.headers["Warning"] = (
|
|
125
|
+
f'299 - "Deprecated API: please use /api/resources/{resource_type}"'
|
|
126
|
+
)
|
|
127
|
+
response.status_code = 299
|
|
128
|
+
return response
|
|
115
129
|
|
|
116
130
|
return application
|
|
117
131
|
|
|
118
132
|
|
|
119
133
|
app = init_app()
|
|
120
|
-
logs.application_log(
|
|
121
|
-
event=f"Sovereign started and listening on {asgi_config.host}:{asgi_config.port}"
|
|
122
|
-
)
|
|
123
134
|
|
|
124
135
|
|
|
125
136
|
if __name__ == "__main__": # pragma: no cover
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sovereign Cache Module
|
|
3
|
+
|
|
4
|
+
This module provides an extensible cache backend system that allows clients
|
|
5
|
+
to configure their own remote cache backends through entry points.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from typing import Any
|
|
10
|
+
from typing_extensions import final
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
from sovereign import WORKER_URL, stats, application_logger as log
|
|
15
|
+
from sovereign.types import DiscoveryRequest, RegisterClientRequest
|
|
16
|
+
from sovereign.configuration import config
|
|
17
|
+
from sovereign.cache.types import Entry, CacheResult
|
|
18
|
+
from sovereign.cache.backends import CacheBackend, get_backend
|
|
19
|
+
from sovereign.cache.filesystem import FilesystemCache
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
CACHE_READ_TIMEOUT = config.cache.read_timeout
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CacheManagerBase:
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self.local: FilesystemCache = FilesystemCache()
|
|
28
|
+
self.remote: CacheBackend | None = get_backend()
|
|
29
|
+
if self.remote is None:
|
|
30
|
+
log.info("Cache initialized with filesystem backend only")
|
|
31
|
+
else:
|
|
32
|
+
log.info("Cache initialized with filesystem and remote backends")
|
|
33
|
+
|
|
34
|
+
# Client Id registration
|
|
35
|
+
|
|
36
|
+
def register(self, req: DiscoveryRequest):
|
|
37
|
+
id = client_id(req)
|
|
38
|
+
log.debug(f"Registering client {id}")
|
|
39
|
+
self.local.register(id, req)
|
|
40
|
+
stats.increment("client.registration", tags=["status:registered"])
|
|
41
|
+
return id, req
|
|
42
|
+
|
|
43
|
+
def registered(self, req: DiscoveryRequest) -> bool:
|
|
44
|
+
ret = False
|
|
45
|
+
id = client_id(req)
|
|
46
|
+
if value := self.local.registered(id):
|
|
47
|
+
ret = value
|
|
48
|
+
log.debug(f"Client {id} registered={ret}")
|
|
49
|
+
return ret
|
|
50
|
+
|
|
51
|
+
def get_registered_clients(self) -> list[tuple[str, DiscoveryRequest]]:
|
|
52
|
+
if value := self.local.get_registered_clients():
|
|
53
|
+
return value
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@final
|
|
58
|
+
class CacheReader(CacheManagerBase):
|
|
59
|
+
def try_read(self, key: str) -> CacheResult | None:
|
|
60
|
+
# Try filesystem first
|
|
61
|
+
if value := self.local.get(key):
|
|
62
|
+
stats.increment("cache.fs.hit")
|
|
63
|
+
return CacheResult(value=value, from_remote=False)
|
|
64
|
+
stats.increment("cache.fs.miss")
|
|
65
|
+
|
|
66
|
+
# Fallback to remote cache if available
|
|
67
|
+
if self.remote:
|
|
68
|
+
try:
|
|
69
|
+
if value := self.remote.get(key):
|
|
70
|
+
ret = CacheResult(value=value, from_remote=True)
|
|
71
|
+
stats.increment("cache.remote.hit")
|
|
72
|
+
return ret
|
|
73
|
+
except Exception as e:
|
|
74
|
+
log.warning(f"Failed to read from remote cache: {e}")
|
|
75
|
+
stats.increment("cache.remote.error")
|
|
76
|
+
stats.increment("cache.remote.miss")
|
|
77
|
+
log.warning(f"Failed to read from either cache for {key}")
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
def get(self, req: DiscoveryRequest) -> Entry | None:
|
|
81
|
+
id = client_id(req)
|
|
82
|
+
if result := self.try_read(id):
|
|
83
|
+
if result.from_remote:
|
|
84
|
+
self.register(req)
|
|
85
|
+
# Write back to filesystem
|
|
86
|
+
self.local.set(id, result.value)
|
|
87
|
+
return result.value
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
@stats.timed("cache.read_ms")
|
|
91
|
+
async def blocking_read(
|
|
92
|
+
self, req: DiscoveryRequest, timeout_s=CACHE_READ_TIMEOUT, poll_interval_s=0.5
|
|
93
|
+
) -> Entry | None:
|
|
94
|
+
cid = client_id(req)
|
|
95
|
+
metric = "client.registration"
|
|
96
|
+
if entry := self.get(req):
|
|
97
|
+
return entry
|
|
98
|
+
|
|
99
|
+
log.info(f"Cache entry not found for {cid}, registering and waiting")
|
|
100
|
+
registered = False
|
|
101
|
+
start = asyncio.get_event_loop().time()
|
|
102
|
+
attempt = 1
|
|
103
|
+
while (asyncio.get_event_loop().time() - start) < timeout_s:
|
|
104
|
+
if not registered:
|
|
105
|
+
try:
|
|
106
|
+
if self.register_over_http(req):
|
|
107
|
+
stats.increment(metric, tags=["status:registered"])
|
|
108
|
+
registered = True
|
|
109
|
+
log.info(f"Client {cid} registered")
|
|
110
|
+
else:
|
|
111
|
+
stats.increment(metric, tags=["status:ratelimited"])
|
|
112
|
+
await asyncio.sleep(min(attempt, CACHE_READ_TIMEOUT))
|
|
113
|
+
attempt *= 2
|
|
114
|
+
except Exception as e:
|
|
115
|
+
stats.increment(metric, tags=["status:failed"])
|
|
116
|
+
log.exception(f"Tried to register client but failed: {e}")
|
|
117
|
+
if entry := self.get(req):
|
|
118
|
+
log.info(f"Entry has been populated for {cid}")
|
|
119
|
+
return entry
|
|
120
|
+
await asyncio.sleep(poll_interval_s)
|
|
121
|
+
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
def register_over_http(self, req: DiscoveryRequest) -> bool:
|
|
125
|
+
registration = RegisterClientRequest(request=req)
|
|
126
|
+
log.debug(f"Sending registration to worker for {req}")
|
|
127
|
+
try:
|
|
128
|
+
response = requests.put(
|
|
129
|
+
f"{WORKER_URL}/client",
|
|
130
|
+
json=registration.model_dump(),
|
|
131
|
+
timeout=3,
|
|
132
|
+
)
|
|
133
|
+
match response.status_code:
|
|
134
|
+
case 200 | 202:
|
|
135
|
+
log.debug("Worker responded OK to registration")
|
|
136
|
+
return True
|
|
137
|
+
case code:
|
|
138
|
+
log.debug(f"Worker responded with {code} to registration")
|
|
139
|
+
except Exception as e:
|
|
140
|
+
log.exception(f"Error while registering client: {e}")
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@final
|
|
145
|
+
class CacheWriter(CacheManagerBase):
|
|
146
|
+
def set(
|
|
147
|
+
self, key: str, value: Entry, timeout: int | None = None
|
|
148
|
+
) -> tuple[bool, list[tuple[str, str]]]:
|
|
149
|
+
msg = []
|
|
150
|
+
cached = False
|
|
151
|
+
try:
|
|
152
|
+
self.local.set(key, value, timeout)
|
|
153
|
+
stats.increment("cache.fs.write.success")
|
|
154
|
+
cached = True
|
|
155
|
+
except Exception as e:
|
|
156
|
+
log.warning(f"Failed to write to filesystem cache: {e}")
|
|
157
|
+
msg.append(("warning", f"Failed to write to filesystem cache: {e}"))
|
|
158
|
+
stats.increment("cache.fs.write.error")
|
|
159
|
+
if self.remote:
|
|
160
|
+
try:
|
|
161
|
+
self.remote.set(key, value, timeout)
|
|
162
|
+
stats.increment("cache.remote.write.success")
|
|
163
|
+
cached = True
|
|
164
|
+
except Exception as e:
|
|
165
|
+
log.warning(f"Failed to write to remote cache: {e}")
|
|
166
|
+
msg.append(("warning", f"Failed to write to remote cache: {e}"))
|
|
167
|
+
stats.increment("cache.remote.write.error")
|
|
168
|
+
return cached, msg
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def client_id(req: DiscoveryRequest) -> str:
|
|
172
|
+
return req.cache_key(config.cache.hash_rules)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cache backends module
|
|
3
|
+
|
|
4
|
+
This module provides the protocol definition for cache backends and
|
|
5
|
+
the loading mechanism for extensible cache backends via entry points.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from importlib.metadata import EntryPoints
|
|
10
|
+
from typing import Protocol, Any, runtime_checkable
|
|
11
|
+
|
|
12
|
+
from sovereign import application_logger as log
|
|
13
|
+
from sovereign.utils.entry_point_loader import EntryPointLoader
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@runtime_checkable
|
|
17
|
+
class CacheBackend(Protocol):
|
|
18
|
+
def __init__(self, config: dict[str, Any]) -> None:
|
|
19
|
+
"""Initialize the cache backend with generic configuration
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
config: Dictionary containing backend-specific configuration
|
|
23
|
+
"""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
def get(self, key: str) -> Any | None:
|
|
27
|
+
"""Get a value from the cache
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
key: The cache key
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
The cached value or None if not found
|
|
34
|
+
"""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
def set(self, key: str, value: Any, timeout: int | None = None) -> None:
|
|
38
|
+
"""Set a value in the cache
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
key: The cache key
|
|
42
|
+
value: The value to cache
|
|
43
|
+
timeout: Optional timeout in seconds
|
|
44
|
+
"""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_backend() -> CacheBackend | None:
|
|
49
|
+
from sovereign import config
|
|
50
|
+
|
|
51
|
+
cache_config = config.cache.remote_backend
|
|
52
|
+
if not cache_config:
|
|
53
|
+
log.info("No remote cache backend configured, using filesystem only")
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
backend_type = cache_config.type
|
|
57
|
+
|
|
58
|
+
loader = EntryPointLoader("cache.backends")
|
|
59
|
+
entry_points: EntryPoints | Sequence[Any] = loader.groups.get("cache.backends", [])
|
|
60
|
+
|
|
61
|
+
backend = None
|
|
62
|
+
for ep in entry_points:
|
|
63
|
+
if ep.name == backend_type:
|
|
64
|
+
backend = ep.load()
|
|
65
|
+
break
|
|
66
|
+
|
|
67
|
+
if not backend:
|
|
68
|
+
raise KeyError(
|
|
69
|
+
(
|
|
70
|
+
f"Cache backend '{backend_type}' not found. "
|
|
71
|
+
f"Available backends: {[ep.name for ep in entry_points]}"
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
backend_config = _process_loadable_config(cache_config.config)
|
|
76
|
+
instance = backend(backend_config)
|
|
77
|
+
|
|
78
|
+
if not isinstance(instance, CacheBackend):
|
|
79
|
+
raise TypeError(
|
|
80
|
+
(f"Cache backend '{backend_type}' does not implement CacheBackend protocol")
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
log.info(f"Successfully initialized cache backend: {backend_type}")
|
|
84
|
+
return instance
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _process_loadable_config(config: dict[str, Any]) -> dict[str, Any]:
|
|
88
|
+
from sovereign.dynamic_config import Loadable
|
|
89
|
+
|
|
90
|
+
processed = {}
|
|
91
|
+
for key, value in config.items():
|
|
92
|
+
try:
|
|
93
|
+
if isinstance(value, str):
|
|
94
|
+
loadable = Loadable.from_legacy_fmt(value)
|
|
95
|
+
processed[key] = loadable.load()
|
|
96
|
+
elif isinstance(value, dict):
|
|
97
|
+
loadable = Loadable(**value)
|
|
98
|
+
processed[key] = loadable.load()
|
|
99
|
+
else:
|
|
100
|
+
processed[key] = value
|
|
101
|
+
continue
|
|
102
|
+
except Exception as e:
|
|
103
|
+
log.warning(f"Failed to load value for {key}: {e}")
|
|
104
|
+
|
|
105
|
+
if isinstance(value, dict):
|
|
106
|
+
processed[key] = _process_loadable_config(value)
|
|
107
|
+
else:
|
|
108
|
+
processed[key] = value
|
|
109
|
+
|
|
110
|
+
return processed
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import pickle
|
|
2
|
+
from datetime import datetime, timezone, timedelta
|
|
3
|
+
from typing import Any
|
|
4
|
+
from typing_extensions import override
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
from importlib.util import find_spec
|
|
7
|
+
|
|
8
|
+
from sovereign import application_logger as log
|
|
9
|
+
from sovereign.cache.backends import CacheBackend
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import boto3
|
|
13
|
+
from botocore.exceptions import ClientError
|
|
14
|
+
except ImportError:
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
BOTO_AVAILABLE = find_spec("boto3") is not None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class S3Client:
|
|
21
|
+
def __init__(self, role_arn: str | None, client_args: dict[str, Any]):
|
|
22
|
+
self.role_arn = role_arn
|
|
23
|
+
self.client_args = client_args
|
|
24
|
+
self._client = None
|
|
25
|
+
self._credentials_expiry = None
|
|
26
|
+
self._base_session = boto3.Session()
|
|
27
|
+
self._make_client()
|
|
28
|
+
|
|
29
|
+
def _make_client(self) -> None:
|
|
30
|
+
if self.role_arn:
|
|
31
|
+
log.debug(f"Refreshing credentials for role: {self.role_arn}")
|
|
32
|
+
sts = self._base_session.client("sts")
|
|
33
|
+
duration_seconds = 3600 # 4 hours
|
|
34
|
+
response = sts.assume_role(
|
|
35
|
+
RoleArn=self.role_arn,
|
|
36
|
+
RoleSessionName="sovereign-s3-cache",
|
|
37
|
+
DurationSeconds=duration_seconds,
|
|
38
|
+
)
|
|
39
|
+
credentials = response["Credentials"]
|
|
40
|
+
session = boto3.Session(
|
|
41
|
+
aws_access_key_id=credentials["AccessKeyId"],
|
|
42
|
+
aws_secret_access_key=credentials["SecretAccessKey"],
|
|
43
|
+
aws_session_token=credentials["SessionToken"],
|
|
44
|
+
)
|
|
45
|
+
self._credentials_expiry = credentials["Expiration"]
|
|
46
|
+
else:
|
|
47
|
+
session = self._base_session
|
|
48
|
+
self._credentials_expiry = None
|
|
49
|
+
self._client = session.client("s3", **self.client_args)
|
|
50
|
+
|
|
51
|
+
def _session_expiring_soon(self) -> bool:
|
|
52
|
+
if not self.role_arn or self._credentials_expiry is None:
|
|
53
|
+
return False
|
|
54
|
+
refresh_threshold = timedelta(minutes=30).seconds
|
|
55
|
+
time_until_expiry = (
|
|
56
|
+
self._credentials_expiry - datetime.now(timezone.utc)
|
|
57
|
+
).total_seconds()
|
|
58
|
+
return time_until_expiry <= refresh_threshold
|
|
59
|
+
|
|
60
|
+
def __getattr__(self, name):
|
|
61
|
+
if self._session_expiring_soon():
|
|
62
|
+
self._make_client()
|
|
63
|
+
return getattr(self._client, name)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class S3Backend(CacheBackend):
|
|
67
|
+
"""S3 cache backend implementation"""
|
|
68
|
+
|
|
69
|
+
@override
|
|
70
|
+
def __init__(self, config: dict[str, Any]) -> None: # pyright: ignore[reportMissingSuperCall]
|
|
71
|
+
"""Initialize S3 backend
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
config: Configuration dictionary containing S3 connection parameters
|
|
75
|
+
Expected keys: bucket_name, prefix
|
|
76
|
+
Optional keys: assume_role, endpoint_url
|
|
77
|
+
"""
|
|
78
|
+
if not BOTO_AVAILABLE:
|
|
79
|
+
raise ImportError("boto3 not installed")
|
|
80
|
+
|
|
81
|
+
self.bucket_name = config.get("bucket_name")
|
|
82
|
+
if not self.bucket_name:
|
|
83
|
+
raise ValueError("bucket_name is required for S3 cache backend")
|
|
84
|
+
|
|
85
|
+
self.prefix = config.get("prefix", "sovereign-cache")
|
|
86
|
+
self.registration_prefix = config.get("registration_prefix", "registrations-")
|
|
87
|
+
self.role = config.get("assume_role")
|
|
88
|
+
|
|
89
|
+
client_args: dict[str, Any] = {}
|
|
90
|
+
if endpoint_url := config.get("endpoint_url"):
|
|
91
|
+
client_args["endpoint_url"] = endpoint_url
|
|
92
|
+
|
|
93
|
+
self.client_args = client_args
|
|
94
|
+
self.s3 = S3Client(self.role, self.client_args)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
self.s3.head_bucket(Bucket=self.bucket_name)
|
|
98
|
+
log.info(f"S3 cache backend connected to bucket '{self.bucket_name}'")
|
|
99
|
+
except Exception as e:
|
|
100
|
+
log.error(
|
|
101
|
+
f"Failed to access S3 bucket '{self.bucket_name}' with current credentials: {e}"
|
|
102
|
+
)
|
|
103
|
+
raise
|
|
104
|
+
|
|
105
|
+
def _make_key(self, key: str) -> str:
|
|
106
|
+
encoded_key = quote(key, safe="")
|
|
107
|
+
return f"{self.prefix}/{encoded_key}"
|
|
108
|
+
|
|
109
|
+
def get(self, key: str) -> Any | None:
|
|
110
|
+
try:
|
|
111
|
+
log.debug(f"Retrieving object {key} from bucket")
|
|
112
|
+
response = self.s3.get_object(
|
|
113
|
+
Bucket=self.bucket_name, Key=self._make_key(key)
|
|
114
|
+
)
|
|
115
|
+
data = response["Body"].read()
|
|
116
|
+
unpickled = pickle.loads(data)
|
|
117
|
+
log.debug(f"Successfully obtained object {key} from bucket")
|
|
118
|
+
return unpickled
|
|
119
|
+
except self.s3.exceptions.NoSuchKey:
|
|
120
|
+
log.debug(f"{key} not in bucket")
|
|
121
|
+
return None
|
|
122
|
+
except ClientError as e:
|
|
123
|
+
if e.response["Error"]["Code"] == "404":
|
|
124
|
+
log.debug(f"{key} not in bucket")
|
|
125
|
+
return None
|
|
126
|
+
log.warning(f"Failed to get key '{key}' from S3: {e}")
|
|
127
|
+
return None
|
|
128
|
+
except Exception as e:
|
|
129
|
+
log.warning(f"Failed to get key '{key}' from S3: {e}")
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
@override
|
|
133
|
+
def set(self, key: str, value: Any, timeout: int | None = None) -> None:
|
|
134
|
+
try:
|
|
135
|
+
log.debug(f"Putting new object {key} into bucket")
|
|
136
|
+
self.s3.put_object(
|
|
137
|
+
Bucket=self.bucket_name,
|
|
138
|
+
Key=self._make_key(key),
|
|
139
|
+
Body=pickle.dumps(value),
|
|
140
|
+
)
|
|
141
|
+
except Exception as e:
|
|
142
|
+
log.warning(f"Failed to set key '{key}' in S3: {e}")
|
|
143
|
+
raise
|