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.
- sovereign/__init__.py +13 -81
- sovereign/app.py +62 -48
- sovereign/cache/__init__.py +245 -0
- sovereign/cache/backends/__init__.py +110 -0
- sovereign/cache/backends/s3.py +161 -0
- sovereign/cache/filesystem.py +74 -0
- sovereign/cache/types.py +17 -0
- sovereign/configuration.py +607 -0
- sovereign/constants.py +1 -0
- sovereign/context.py +270 -104
- sovereign/dynamic_config/__init__.py +112 -0
- sovereign/dynamic_config/deser.py +78 -0
- sovereign/dynamic_config/loaders.py +120 -0
- sovereign/error_info.py +2 -3
- 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 +2 -1
- sovereign/rendering.py +124 -0
- sovereign/rendering_common.py +91 -0
- sovereign/response_class.py +18 -0
- sovereign/server.py +112 -35
- sovereign/statistics.py +19 -21
- sovereign/templates/base.html +59 -46
- sovereign/templates/resources.html +203 -102
- sovereign/testing/loaders.py +9 -0
- sovereign/{modifiers/test.py → testing/modifiers.py} +0 -2
- sovereign/tracing.py +103 -0
- sovereign/types.py +304 -0
- sovereign/utils/auth.py +27 -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 +3 -2
- sovereign/utils/eds.py +40 -22
- sovereign/utils/entry_point_loader.py +2 -2
- sovereign/utils/mock.py +56 -17
- sovereign/utils/resources.py +17 -0
- sovereign/utils/templates.py +4 -2
- sovereign/utils/timer.py +5 -3
- sovereign/utils/version_info.py +8 -0
- sovereign/utils/weighted_clusters.py +2 -1
- sovereign/v2/__init__.py +0 -0
- sovereign/v2/data/data_store.py +621 -0
- sovereign/v2/data/render_discovery_response.py +24 -0
- sovereign/v2/data/repositories.py +90 -0
- sovereign/v2/data/utils.py +33 -0
- sovereign/v2/data/worker_queue.py +273 -0
- sovereign/v2/jobs/refresh_context.py +117 -0
- sovereign/v2/jobs/render_discovery_job.py +145 -0
- sovereign/v2/logging.py +81 -0
- sovereign/v2/types.py +41 -0
- sovereign/v2/web.py +101 -0
- sovereign/v2/worker.py +199 -0
- sovereign/views/__init__.py +7 -0
- sovereign/views/api.py +82 -0
- sovereign/views/crypto.py +46 -15
- sovereign/views/discovery.py +55 -119
- sovereign/views/healthchecks.py +107 -20
- sovereign/views/interface.py +171 -111
- sovereign/worker.py +193 -0
- {sovereign-0.19.3.dist-info → sovereign-1.0.0a4.dist-info}/METADATA +80 -76
- sovereign-1.0.0a4.dist-info/RECORD +85 -0
- {sovereign-0.19.3.dist-info → sovereign-1.0.0a4.dist-info}/WHEEL +1 -1
- sovereign-1.0.0a4.dist-info/entry_points.txt +46 -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/sources/__init__.py +0 -3
- sovereign/sources/file.py +0 -21
- sovereign/sources/inline.py +0 -38
- sovereign/sources/lib.py +0 -40
- sovereign/sources/poller.py +0 -294
- 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 pydantic.error_wrappers import ValidationError
|
|
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.configuration import EncryptionConfig, config
|
|
5
|
+
from sovereign.logging.bootstrapper import LoggerBootstrapper
|
|
18
6
|
from sovereign.statistics import configure_statsd
|
|
19
|
-
from sovereign.utils.
|
|
20
|
-
from sovereign.
|
|
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
|
-
|
|
7
|
+
from sovereign.utils.crypto.crypto import CipherContainer
|
|
8
|
+
from sovereign.utils.crypto.suites import EncryptionType
|
|
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
|
-
asgi_config,
|
|
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 ConfiguredResourceTypes, config
|
|
17
11
|
from sovereign.error_info import ErrorInfo
|
|
18
|
-
from sovereign.
|
|
19
|
-
from sovereign.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
)
|
|
12
|
+
from sovereign.middlewares import LoggingMiddleware, RequestContextLogMiddleware
|
|
13
|
+
from sovereign.response_class import json_response_class
|
|
14
|
+
from sovereign.utils.resources import get_package_file
|
|
15
|
+
from sovereign.views import api, crypto, discovery, healthchecks, interface
|
|
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,35 @@ 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
|
+
# noinspection PyUnusedImports
|
|
72
|
+
import sentry_sdk
|
|
73
|
+
|
|
74
|
+
# noinspection PyUnusedImports
|
|
75
|
+
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
|
|
84
76
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
77
|
+
sentry_sdk.init(dsn)
|
|
78
|
+
application.add_middleware(SentryAsgiMiddleware) # type: ignore
|
|
79
|
+
except ImportError:
|
|
80
|
+
logs.application_logger.logger.error(
|
|
81
|
+
"Sentry DSN configured but failed to attach to webserver"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
application.add_middleware(RawContextMiddleware) # type: ignore
|
|
89
85
|
|
|
90
86
|
@application.exception_handler(500)
|
|
91
87
|
async def exception_handler(_: Request, exc: Exception) -> JSONResponse:
|
|
@@ -103,23 +99,41 @@ def init_app() -> FastAPI:
|
|
|
103
99
|
|
|
104
100
|
@application.get("/static/{filename}", summary="Return a static asset")
|
|
105
101
|
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())
|
|
102
|
+
return FileResponse(get_package_file("sovereign_files", f"static/{filename}")) # type: ignore[arg-type]
|
|
111
103
|
|
|
112
|
-
@application.
|
|
113
|
-
|
|
114
|
-
|
|
104
|
+
@application.get(
|
|
105
|
+
"/admin/xds_dump",
|
|
106
|
+
summary="Deprecated API, please use /api/resources/{resource_type}",
|
|
107
|
+
)
|
|
108
|
+
async def dump_resources(request: Request) -> Response:
|
|
109
|
+
resource_type = ConfiguredResourceTypes(
|
|
110
|
+
request.query_params.get("xds_type", "cluster")
|
|
111
|
+
)
|
|
112
|
+
resource_name = request.query_params.get("name")
|
|
113
|
+
api_version = request.query_params.get("api_version", "v3")
|
|
114
|
+
service_cluster = request.query_params.get("service_cluster", "*")
|
|
115
|
+
region = request.query_params.get("region")
|
|
116
|
+
version = request.query_params.get("version")
|
|
117
|
+
response = await api.resource(
|
|
118
|
+
resource_type=resource_type,
|
|
119
|
+
resource_name=resource_name,
|
|
120
|
+
api_version=api_version,
|
|
121
|
+
service_cluster=service_cluster,
|
|
122
|
+
region=region,
|
|
123
|
+
version=version,
|
|
124
|
+
)
|
|
125
|
+
response.headers["Deprecation"] = "true"
|
|
126
|
+
response.headers["Link"] = f'</api/resources/{resource_type}>; rel="alternate"'
|
|
127
|
+
response.headers["Warning"] = (
|
|
128
|
+
f'299 - "Deprecated API: please use /api/resources/{resource_type}"'
|
|
129
|
+
)
|
|
130
|
+
response.status_code = 299
|
|
131
|
+
return response
|
|
115
132
|
|
|
116
133
|
return application
|
|
117
134
|
|
|
118
135
|
|
|
119
136
|
app = init_app()
|
|
120
|
-
logs.application_log(
|
|
121
|
-
event=f"Sovereign started and listening on {asgi_config.host}:{asgi_config.port}"
|
|
122
|
-
)
|
|
123
137
|
|
|
124
138
|
|
|
125
139
|
if __name__ == "__main__": # pragma: no cover
|
|
@@ -0,0 +1,245 @@
|
|
|
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
|
+
import os
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
from typing_extensions import final
|
|
15
|
+
|
|
16
|
+
from sovereign import WORKER_URL, stats
|
|
17
|
+
from sovereign import application_logger as log
|
|
18
|
+
from sovereign.cache.backends import CacheBackend, get_backend
|
|
19
|
+
from sovereign.cache.filesystem import FilesystemCache
|
|
20
|
+
from sovereign.cache.types import CacheResult, Entry
|
|
21
|
+
from sovereign.configuration import config
|
|
22
|
+
from sovereign.types import DiscoveryRequest, RegisterClientRequest
|
|
23
|
+
|
|
24
|
+
CACHE_READ_TIMEOUT = config.cache.read_timeout
|
|
25
|
+
REMOTE_TTL = 300 # 5 minutes - TTL for entries read from remote cache
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CacheManagerBase:
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self.local: FilesystemCache = FilesystemCache()
|
|
31
|
+
self.remote: CacheBackend | None = get_backend()
|
|
32
|
+
if self.remote is None:
|
|
33
|
+
log.info("Cache initialized with filesystem backend only")
|
|
34
|
+
else:
|
|
35
|
+
log.info("Cache initialized with filesystem and remote backends")
|
|
36
|
+
|
|
37
|
+
# Client Id registration
|
|
38
|
+
|
|
39
|
+
def register(self, req: DiscoveryRequest):
|
|
40
|
+
id = client_id(req)
|
|
41
|
+
log.debug(f"Registering client {id}")
|
|
42
|
+
self.local.register(id, req)
|
|
43
|
+
stats.increment("client.registration", tags=["status:registered"])
|
|
44
|
+
return id, req
|
|
45
|
+
|
|
46
|
+
def registered(self, req: DiscoveryRequest) -> bool:
|
|
47
|
+
ret = False
|
|
48
|
+
id = client_id(req)
|
|
49
|
+
if value := self.local.registered(id):
|
|
50
|
+
ret = value
|
|
51
|
+
log.debug(f"Client {id} registered={ret}")
|
|
52
|
+
return ret
|
|
53
|
+
|
|
54
|
+
def get_registered_clients(self) -> list[tuple[str, DiscoveryRequest]]:
|
|
55
|
+
if value := self.local.get_registered_clients():
|
|
56
|
+
return value
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@final
|
|
61
|
+
class CacheReader(CacheManagerBase):
|
|
62
|
+
def try_read(self, key: str) -> CacheResult | None:
|
|
63
|
+
# Try filesystem first
|
|
64
|
+
if value := self.local.get(key):
|
|
65
|
+
stats.increment("cache.fs.hit")
|
|
66
|
+
return CacheResult(value=value, from_remote=False)
|
|
67
|
+
stats.increment("cache.fs.miss")
|
|
68
|
+
|
|
69
|
+
# Fallback to remote cache if available
|
|
70
|
+
if self.remote:
|
|
71
|
+
try:
|
|
72
|
+
if value := self.remote.get(key):
|
|
73
|
+
ret = CacheResult(value=value, from_remote=True)
|
|
74
|
+
stats.increment("cache.remote.hit")
|
|
75
|
+
return ret
|
|
76
|
+
except Exception as e:
|
|
77
|
+
log.warning(f"Failed to read from remote cache: {e}")
|
|
78
|
+
stats.increment("cache.remote.error")
|
|
79
|
+
stats.increment("cache.remote.miss")
|
|
80
|
+
log.warning(f"Failed to read from either cache for {key}")
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
def get(self, req: DiscoveryRequest) -> Entry | None:
|
|
84
|
+
"""Read from cache, writing back from remote with short TTL if needed.
|
|
85
|
+
|
|
86
|
+
Flow:
|
|
87
|
+
1. Entry read from remote → cached with 300s TTL
|
|
88
|
+
2. Background registration triggers worker to generate fresh config
|
|
89
|
+
3. Remote entry expires after 300s
|
|
90
|
+
4. Next request gets worker-generated config (cached infinitely)
|
|
91
|
+
"""
|
|
92
|
+
id = client_id(req)
|
|
93
|
+
if result := self.try_read(id):
|
|
94
|
+
if result.from_remote:
|
|
95
|
+
# Write immediately with short TTL to prevent empty cache window
|
|
96
|
+
self.local.set(id, result.value, timeout=REMOTE_TTL)
|
|
97
|
+
log.info(
|
|
98
|
+
f"Cache writeback from remote: client_id={id} version={result.value.version} "
|
|
99
|
+
f"ttl={REMOTE_TTL} type=remote pid={os.getpid()}"
|
|
100
|
+
)
|
|
101
|
+
stats.increment("cache.fs.writeback", tags=["type:remote"])
|
|
102
|
+
|
|
103
|
+
# Background thread triggers worker to generate fresh config
|
|
104
|
+
self.register_async(req)
|
|
105
|
+
return result.value
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
@stats.timed("cache.read_ms")
|
|
109
|
+
async def blocking_read(
|
|
110
|
+
self, req: DiscoveryRequest, timeout_s=CACHE_READ_TIMEOUT, poll_interval_s=0.5
|
|
111
|
+
) -> Entry | None:
|
|
112
|
+
cid = client_id(req)
|
|
113
|
+
metric = "client.registration"
|
|
114
|
+
if entry := self.get(req):
|
|
115
|
+
return entry
|
|
116
|
+
|
|
117
|
+
log.info(f"Cache entry not found for {cid}, registering and waiting")
|
|
118
|
+
registered = False
|
|
119
|
+
start = asyncio.get_event_loop().time()
|
|
120
|
+
attempt = 1
|
|
121
|
+
while (asyncio.get_event_loop().time() - start) < timeout_s:
|
|
122
|
+
if not registered:
|
|
123
|
+
try:
|
|
124
|
+
if self.register_over_http(req):
|
|
125
|
+
stats.increment(metric, tags=["status:registered"])
|
|
126
|
+
registered = True
|
|
127
|
+
log.info(f"Client {cid} registered")
|
|
128
|
+
else:
|
|
129
|
+
stats.increment(metric, tags=["status:ratelimited"])
|
|
130
|
+
await asyncio.sleep(min(attempt, CACHE_READ_TIMEOUT))
|
|
131
|
+
attempt *= 2
|
|
132
|
+
except Exception as e:
|
|
133
|
+
stats.increment(metric, tags=["status:failed"])
|
|
134
|
+
log.exception(f"Tried to register client but failed: {e}")
|
|
135
|
+
if entry := self.get(req):
|
|
136
|
+
log.info(f"Entry has been populated for {cid}")
|
|
137
|
+
return entry
|
|
138
|
+
await asyncio.sleep(poll_interval_s)
|
|
139
|
+
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
def register_over_http(self, req: DiscoveryRequest) -> bool:
|
|
143
|
+
registration = RegisterClientRequest(request=req)
|
|
144
|
+
log.debug(f"Sending registration to worker for {req}")
|
|
145
|
+
try:
|
|
146
|
+
response = requests.put(
|
|
147
|
+
f"{WORKER_URL}/client",
|
|
148
|
+
json=registration.model_dump(),
|
|
149
|
+
timeout=3,
|
|
150
|
+
)
|
|
151
|
+
match response.status_code:
|
|
152
|
+
case 200 | 202:
|
|
153
|
+
log.debug("Worker responded OK to registration")
|
|
154
|
+
return True
|
|
155
|
+
case code:
|
|
156
|
+
log.debug(f"Worker responded with {code} to registration")
|
|
157
|
+
except Exception as e:
|
|
158
|
+
log.exception(f"Error while registering client: {e}")
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
def register_async(self, req: DiscoveryRequest):
|
|
162
|
+
"""Register client async to trigger worker to generate fresh config.
|
|
163
|
+
|
|
164
|
+
Registration tells the worker about this client so it generates fresh config.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
def job():
|
|
168
|
+
start_time = time.time()
|
|
169
|
+
attempts = 5
|
|
170
|
+
backoff = 1.0
|
|
171
|
+
attempt_num = 0
|
|
172
|
+
|
|
173
|
+
while attempts:
|
|
174
|
+
attempt_num += 1
|
|
175
|
+
if self.register_over_http(req):
|
|
176
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
177
|
+
stats.increment(
|
|
178
|
+
"client.registration.async",
|
|
179
|
+
tags=["status:success", f"attempts:{attempt_num}"],
|
|
180
|
+
)
|
|
181
|
+
stats.timing("client.registration.async.duration_ms", duration_ms)
|
|
182
|
+
log.debug(f"Async registration succeeded: attempts={attempt_num}")
|
|
183
|
+
return
|
|
184
|
+
attempts -= 1
|
|
185
|
+
if attempts:
|
|
186
|
+
log.debug(
|
|
187
|
+
f"Async registration failed: retrying_in={backoff}s remaining={attempts}"
|
|
188
|
+
)
|
|
189
|
+
time.sleep(backoff)
|
|
190
|
+
backoff *= 2
|
|
191
|
+
|
|
192
|
+
# Registration failed - entry stays at REMOTE_TTL, will expire and retry
|
|
193
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
194
|
+
stats.increment("client.registration.async", tags=["status:exhausted"])
|
|
195
|
+
stats.timing("client.registration.async.duration_ms", duration_ms)
|
|
196
|
+
log.warning(
|
|
197
|
+
f"Async registration exhausted for {req}: remote entry will expire "
|
|
198
|
+
f"in {REMOTE_TTL}s and retry"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
t = threading.Thread(target=job)
|
|
202
|
+
t.start()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@final
|
|
206
|
+
class CacheWriter(CacheManagerBase):
|
|
207
|
+
def set(
|
|
208
|
+
self, key: str, value: Entry, timeout: int | None = None
|
|
209
|
+
) -> tuple[bool, list[tuple[str, str]]]:
|
|
210
|
+
msg = []
|
|
211
|
+
cached = False
|
|
212
|
+
try:
|
|
213
|
+
self.local.set(key, value, timeout)
|
|
214
|
+
log.info(
|
|
215
|
+
f"Cache write to filesystem: client_id={key} version={value.version} "
|
|
216
|
+
f"ttl={timeout} pid={os.getpid()} thread_id={threading.get_ident()}"
|
|
217
|
+
)
|
|
218
|
+
stats.increment("cache.fs.write.success")
|
|
219
|
+
cached = True
|
|
220
|
+
except Exception as e:
|
|
221
|
+
log.warning(
|
|
222
|
+
f"Failed to write to filesystem cache: client_id={key} error={e}"
|
|
223
|
+
)
|
|
224
|
+
msg.append(("warning", f"Failed to write to filesystem cache: {e}"))
|
|
225
|
+
stats.increment("cache.fs.write.error")
|
|
226
|
+
if self.remote:
|
|
227
|
+
try:
|
|
228
|
+
self.remote.set(key, value, timeout)
|
|
229
|
+
log.info(
|
|
230
|
+
f"Cache write to remote: client_id={key} version={value.version} "
|
|
231
|
+
f"ttl={timeout} pid={os.getpid()} thread_id={threading.get_ident()}"
|
|
232
|
+
)
|
|
233
|
+
stats.increment("cache.remote.write.success")
|
|
234
|
+
cached = True
|
|
235
|
+
except Exception as e:
|
|
236
|
+
log.warning(
|
|
237
|
+
f"Failed to write to remote cache: client_id={key} error={e}"
|
|
238
|
+
)
|
|
239
|
+
msg.append(("warning", f"Failed to write to remote cache: {e}"))
|
|
240
|
+
stats.increment("cache.remote.write.error")
|
|
241
|
+
return cached, msg
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def client_id(req: DiscoveryRequest) -> str:
|
|
245
|
+
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 Any, Protocol, 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
|