sovereign 0.14.2__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 +17 -78
  2. sovereign/app.py +74 -59
  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 +271 -100
  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 +61 -0
  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 +123 -28
  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 +18 -0
  45. sovereign/utils/mock.py +60 -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 +52 -67
  67. sovereign/views/healthchecks.py +107 -20
  68. sovereign/views/interface.py +173 -117
  69. sovereign/worker.py +193 -0
  70. {sovereign-0.14.2.dist-info → sovereign-1.0.0a4.dist-info}/METADATA +81 -73
  71. sovereign-1.0.0a4.dist-info/RECORD +85 -0
  72. {sovereign-0.14.2.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 -715
  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 -298
  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 -64
  96. sovereign/views/admin.py +0 -120
  97. sovereign-0.14.2.dist-info/LICENSE.txt +0 -13
  98. sovereign-0.14.2.dist-info/RECORD +0 -45
  99. sovereign-0.14.2.dist-info/entry_points.txt +0 -10
sovereign/__init__.py CHANGED
@@ -1,49 +1,11 @@
1
- import os
2
1
  from contextvars import ContextVar
3
- from typing import Type, Any, Mapping
4
- from pkg_resources import get_distribution, resource_filename
2
+ from importlib.metadata import version
5
3
 
6
- from fastapi.responses import JSONResponse
7
- from starlette.templating import Jinja2Templates
8
- from pydantic.error_wrappers import ValidationError
9
-
10
- from sovereign.schemas import (
11
- SovereignAsgiConfig,
12
- SovereignConfig,
13
- SovereignConfigv2,
14
- )
15
- from sovereign import config_loader
16
- from sovereign.logs import LoggerBootstrapper
4
+ from sovereign.configuration import EncryptionConfig, config
5
+ from sovereign.logging.bootstrapper import LoggerBootstrapper
17
6
  from sovereign.statistics import configure_statsd
18
- from sovereign.utils.dictupdate import merge # type: ignore
19
- from sovereign.sources import SourcePoller
20
- from sovereign.context import TemplateContext
21
- from sovereign.utils.crypto import create_cipher_suite
22
-
23
-
24
- json_response_class: Type[JSONResponse] = JSONResponse
25
- try:
26
- import orjson
27
- from fastapi.responses import ORJSONResponse
28
-
29
- json_response_class = ORJSONResponse
30
- except ImportError:
31
- try:
32
- import ujson
33
- from fastapi.responses import UJSONResponse
34
-
35
- json_response_class = UJSONResponse
36
- except ImportError:
37
- pass
38
-
39
-
40
- def parse_raw_configuration(path: str) -> Mapping[Any, Any]:
41
- ret: Mapping[Any, Any] = dict()
42
- for p in path.split(","):
43
- spec = config_loader.Loadable.from_legacy_fmt(p)
44
- ret = merge(obj_a=ret, obj_b=spec.load(), merge_lists=True)
45
- return ret
46
-
7
+ from sovereign.utils.crypto.crypto import CipherContainer
8
+ from sovereign.utils.crypto.suites import EncryptionType
47
9
 
48
10
  _request_id_ctx_var: ContextVar[str] = ContextVar("request_id", default="")
49
11
 
@@ -52,42 +14,19 @@ def get_request_id() -> str:
52
14
  return _request_id_ctx_var.get()
53
15
 
54
16
 
55
- __version__ = get_distribution("sovereign").version
56
- config_path = os.getenv("SOVEREIGN_CONFIG", "file:///etc/sovereign.yaml")
57
- html_templates = Jinja2Templates(resource_filename("sovereign", "templates"))
58
-
59
- try:
60
- config = SovereignConfigv2(**parse_raw_configuration(config_path))
61
- except ValidationError:
62
- old_config = SovereignConfig(**parse_raw_configuration(config_path))
63
- config = SovereignConfigv2.from_legacy_config(old_config)
64
- asgi_config = SovereignAsgiConfig()
65
- XDS_TEMPLATES = config.xds_templates()
17
+ WORKER_URL = "http://localhost:9080"
18
+ DIST_NAME = "sovereign"
19
+ __version__ = version(DIST_NAME)
66
20
 
21
+ stats = configure_statsd()
67
22
  logs = LoggerBootstrapper(config)
68
- stats = configure_statsd(config=config.statsd)
69
- poller = SourcePoller(
70
- sources=config.sources,
71
- matching_enabled=config.matching.enabled,
72
- node_match_key=config.matching.node_key,
73
- source_match_key=config.matching.source_key,
74
- source_refresh_rate=config.source_config.refresh_rate,
75
- logger=logs.application_log,
76
- stats=stats,
77
- )
78
-
79
- encryption_key = config.authentication.encryption_key.get_secret_value().encode()
80
- cipher_suite = create_cipher_suite(key=encryption_key, logger=logs)
23
+ application_logger = logs.application_logger.logger
81
24
 
82
- template_context = TemplateContext(
83
- refresh_rate=config.template_context.refresh_rate,
84
- refresh_cron=config.template_context.refresh_cron,
85
- configured_context=config.template_context.context,
86
- poller=poller,
87
- encryption_suite=cipher_suite,
88
- disabled_suite=create_cipher_suite(b"", logs),
89
- logger=logs.application_log,
90
- stats=stats,
25
+ encryption_configs = config.authentication.encryption_configs
26
+ server_cipher_container = CipherContainer.from_encryption_configs(
27
+ encryption_configs, logger=application_logger
28
+ )
29
+ disabled_ciphersuite = CipherContainer.from_encryption_configs(
30
+ encryption_configs=[EncryptionConfig("", EncryptionType.DISABLED)],
31
+ logger=application_logger,
91
32
  )
92
- poller.lazy_load_modifiers(config.modifiers)
93
- 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
- get_request_id,
14
- poller,
15
- template_context,
16
- logs,
17
- )
18
- from sovereign.views import crypto, discovery, healthchecks, admin, interface
19
- from sovereign.middlewares import (
20
- RequestContextLogMiddleware,
21
- LoggingMiddleware,
22
- )
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
11
+ from sovereign.error_info import ErrorInfo
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:
@@ -45,46 +29,59 @@ def generic_error_response(e: Exception) -> JSONResponse:
45
29
  The traceback is **always** emitted in logs.
46
30
  """
47
31
  tb = [line for line in traceback.format_exc().split("\n")]
48
- error = {
49
- "error": e.__class__.__name__,
50
- "detail": getattr(e, "detail", "-"),
51
- "request_id": get_request_id(),
52
- }
53
- logs.queue_log_fields(
54
- ERROR=error["error"],
55
- ERROR_DETAIL=error["detail"],
32
+ info = ErrorInfo.from_exception(e)
33
+ logs.access_logger.queue_log_fields(
34
+ ERROR=info.error,
35
+ ERROR_DETAIL=info.detail,
56
36
  TRACEBACK=tb,
57
37
  )
58
38
  # Don't expose tracebacks in responses, but add it to the logs
59
39
  if DEBUG:
60
- error["traceback"] = tb
40
+ info.traceback = tb
61
41
  return json_response_class(
62
- content=error, status_code=getattr(e, "status_code", 500)
42
+ content=info.response, status_code=getattr(e, "status_code", 500)
63
43
  )
64
44
 
65
45
 
66
46
  def init_app() -> FastAPI:
67
- application = FastAPI(title="Sovereign", version=__version__, debug=DEBUG)
47
+ application = FastAPI(
48
+ title="Sovereign",
49
+ version=__version__,
50
+ debug=DEBUG,
51
+ default_response_class=json_response_class,
52
+ )
68
53
 
69
54
  routers = (
70
55
  Router(discovery.router, ["Configuration Discovery"], ""),
71
- Router(crypto.router, ["Cryptographic Utilities"], "/crypto"),
72
- Router(admin.router, ["Debugging Endpoints"], "/admin"),
73
- Router(interface.router, ["User Interface"], "/ui"),
56
+ Router(api.router, ["API"], "/api"),
74
57
  Router(healthchecks.router, ["Healthchecks"], ""),
58
+ Router(interface.router, ["User Interface"], "/ui"),
59
+ Router(crypto.router, ["Cryptographic Utilities"], "/crypto"),
75
60
  )
76
61
  for router in routers:
77
62
  application.include_router(
78
63
  router.module, tags=router.tags, prefix=router.prefix
79
64
  )
80
65
 
81
- application.add_middleware(RequestContextLogMiddleware)
82
- 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
83
76
 
84
- if SENTRY_INSTALLED and SENTRY_DSN:
85
- sentry_sdk.init(SENTRY_DSN)
86
- application.add_middleware(SentryAsgiMiddleware)
87
- 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
88
85
 
89
86
  @application.exception_handler(500)
90
87
  async def exception_handler(_: Request, exc: Exception) -> JSONResponse:
@@ -102,23 +99,41 @@ def init_app() -> FastAPI:
102
99
 
103
100
  @application.get("/static/{filename}", summary="Return a static asset")
104
101
  async def static(filename: str) -> Response:
105
- return FileResponse(resource_filename("sovereign", f"static/{filename}"))
106
-
107
- @application.on_event("startup")
108
- async def keep_sources_uptodate() -> None:
109
- asyncio.create_task(poller.poll_forever())
102
+ return FileResponse(get_package_file("sovereign_files", f"static/{filename}")) # type: ignore[arg-type]
110
103
 
111
- @application.on_event("startup")
112
- async def refresh_template_context() -> None:
113
- 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
114
132
 
115
133
  return application
116
134
 
117
135
 
118
136
  app = init_app()
119
- logs.application_log(
120
- event=f"Sovereign started and listening on {asgi_config.host}:{asgi_config.port}"
121
- )
122
137
 
123
138
 
124
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