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

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

Potentially problematic release.


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

Files changed (99) hide show
  1. sovereign/__init__.py +13 -81
  2. sovereign/app.py +62 -48
  3. sovereign/cache/__init__.py +245 -0
  4. sovereign/cache/backends/__init__.py +110 -0
  5. sovereign/cache/backends/s3.py +161 -0
  6. sovereign/cache/filesystem.py +74 -0
  7. sovereign/cache/types.py +17 -0
  8. sovereign/configuration.py +607 -0
  9. sovereign/constants.py +1 -0
  10. sovereign/context.py +270 -104
  11. sovereign/dynamic_config/__init__.py +112 -0
  12. sovereign/dynamic_config/deser.py +78 -0
  13. sovereign/dynamic_config/loaders.py +120 -0
  14. sovereign/error_info.py +2 -3
  15. sovereign/events.py +49 -0
  16. sovereign/logging/access_logger.py +85 -0
  17. sovereign/logging/application_logger.py +54 -0
  18. sovereign/logging/base_logger.py +41 -0
  19. sovereign/logging/bootstrapper.py +36 -0
  20. sovereign/logging/types.py +10 -0
  21. sovereign/middlewares.py +8 -7
  22. sovereign/modifiers/lib.py +2 -1
  23. sovereign/rendering.py +124 -0
  24. sovereign/rendering_common.py +91 -0
  25. sovereign/response_class.py +18 -0
  26. sovereign/server.py +112 -35
  27. sovereign/statistics.py +19 -21
  28. sovereign/templates/base.html +59 -46
  29. sovereign/templates/resources.html +203 -102
  30. sovereign/testing/loaders.py +9 -0
  31. sovereign/{modifiers/test.py → testing/modifiers.py} +0 -2
  32. sovereign/tracing.py +103 -0
  33. sovereign/types.py +304 -0
  34. sovereign/utils/auth.py +27 -13
  35. sovereign/utils/crypto/__init__.py +0 -0
  36. sovereign/utils/crypto/crypto.py +135 -0
  37. sovereign/utils/crypto/suites/__init__.py +21 -0
  38. sovereign/utils/crypto/suites/aes_gcm_cipher.py +42 -0
  39. sovereign/utils/crypto/suites/base_cipher.py +21 -0
  40. sovereign/utils/crypto/suites/disabled_cipher.py +25 -0
  41. sovereign/utils/crypto/suites/fernet_cipher.py +29 -0
  42. sovereign/utils/dictupdate.py +3 -2
  43. sovereign/utils/eds.py +40 -22
  44. sovereign/utils/entry_point_loader.py +2 -2
  45. sovereign/utils/mock.py +56 -17
  46. sovereign/utils/resources.py +17 -0
  47. sovereign/utils/templates.py +4 -2
  48. sovereign/utils/timer.py +5 -3
  49. sovereign/utils/version_info.py +8 -0
  50. sovereign/utils/weighted_clusters.py +2 -1
  51. sovereign/v2/__init__.py +0 -0
  52. sovereign/v2/data/data_store.py +621 -0
  53. sovereign/v2/data/render_discovery_response.py +24 -0
  54. sovereign/v2/data/repositories.py +90 -0
  55. sovereign/v2/data/utils.py +33 -0
  56. sovereign/v2/data/worker_queue.py +273 -0
  57. sovereign/v2/jobs/refresh_context.py +117 -0
  58. sovereign/v2/jobs/render_discovery_job.py +145 -0
  59. sovereign/v2/logging.py +81 -0
  60. sovereign/v2/types.py +41 -0
  61. sovereign/v2/web.py +101 -0
  62. sovereign/v2/worker.py +199 -0
  63. sovereign/views/__init__.py +7 -0
  64. sovereign/views/api.py +82 -0
  65. sovereign/views/crypto.py +46 -15
  66. sovereign/views/discovery.py +55 -119
  67. sovereign/views/healthchecks.py +107 -20
  68. sovereign/views/interface.py +171 -111
  69. sovereign/worker.py +193 -0
  70. {sovereign-0.19.3.dist-info → sovereign-1.0.0a4.dist-info}/METADATA +80 -76
  71. sovereign-1.0.0a4.dist-info/RECORD +85 -0
  72. {sovereign-0.19.3.dist-info → sovereign-1.0.0a4.dist-info}/WHEEL +1 -1
  73. sovereign-1.0.0a4.dist-info/entry_points.txt +46 -0
  74. sovereign_files/__init__.py +0 -0
  75. sovereign_files/static/darkmode.js +51 -0
  76. sovereign_files/static/node_expression.js +42 -0
  77. sovereign_files/static/panel.js +76 -0
  78. sovereign_files/static/resources.css +246 -0
  79. sovereign_files/static/resources.js +642 -0
  80. sovereign_files/static/sass/style.scss +33 -0
  81. sovereign_files/static/style.css +16143 -0
  82. sovereign_files/static/style.css.map +1 -0
  83. sovereign/config_loader.py +0 -225
  84. sovereign/discovery.py +0 -175
  85. sovereign/logs.py +0 -131
  86. sovereign/schemas.py +0 -780
  87. sovereign/sources/__init__.py +0 -3
  88. sovereign/sources/file.py +0 -21
  89. sovereign/sources/inline.py +0 -38
  90. sovereign/sources/lib.py +0 -40
  91. sovereign/sources/poller.py +0 -294
  92. sovereign/static/sass/style.scss +0 -27
  93. sovereign/static/style.css +0 -13553
  94. sovereign/templates/ul_filter.html +0 -22
  95. sovereign/utils/crypto.py +0 -103
  96. sovereign/views/admin.py +0 -120
  97. sovereign-0.19.3.dist-info/LICENSE.txt +0 -13
  98. sovereign-0.19.3.dist-info/RECORD +0 -47
  99. sovereign-0.19.3.dist-info/entry_points.txt +0 -10
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 fastapi.responses import JSONResponse
8
- from starlette.templating import Jinja2Templates
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.dictupdate import merge # type: ignore
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
-
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
- stats = configure_statsd(config=config.statsd)
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
- fernet_keys = config.authentication.encryption_key
84
- encryption_keys = fernet_keys.get_secret_value().encode().split()
85
- cipher_suite = CipherContainer(
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
- template_context = TemplateContext(
90
- refresh_rate=config.template_context.refresh_rate,
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 RedirectResponse, FileResponse, Response, JSONResponse
7
- from pkg_resources import resource_filename
8
- from sovereign import (
9
- __version__,
10
- config,
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.views import crypto, discovery, healthchecks, admin, interface
19
- from sovereign.middlewares import (
20
- RequestContextLogMiddleware,
21
- LoggingMiddleware,
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(crypto.router, ["Cryptographic Utilities"], "/crypto"),
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
- if SENTRY_INSTALLED and SENTRY_DSN:
86
- sentry_sdk.init(SENTRY_DSN)
87
- application.add_middleware(SentryAsgiMiddleware)
88
- logs.application_log(event="Sentry middleware enabled")
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(resource_filename("sovereign", f"static/{filename}"))
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.on_event("startup")
113
- async def refresh_template_context() -> None:
114
- asyncio.create_task(template_context.start_refresh_context())
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