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.

Files changed (80) hide show
  1. sovereign/__init__.py +13 -81
  2. sovereign/app.py +59 -48
  3. sovereign/cache/__init__.py +172 -0
  4. sovereign/cache/backends/__init__.py +110 -0
  5. sovereign/cache/backends/s3.py +143 -0
  6. sovereign/cache/filesystem.py +73 -0
  7. sovereign/cache/types.py +15 -0
  8. sovereign/configuration.py +573 -0
  9. sovereign/constants.py +1 -0
  10. sovereign/context.py +271 -104
  11. sovereign/dynamic_config/__init__.py +113 -0
  12. sovereign/dynamic_config/deser.py +78 -0
  13. sovereign/dynamic_config/loaders.py +120 -0
  14. sovereign/events.py +49 -0
  15. sovereign/logging/access_logger.py +85 -0
  16. sovereign/logging/application_logger.py +54 -0
  17. sovereign/logging/base_logger.py +41 -0
  18. sovereign/logging/bootstrapper.py +36 -0
  19. sovereign/logging/types.py +10 -0
  20. sovereign/middlewares.py +8 -7
  21. sovereign/modifiers/lib.py +1 -0
  22. sovereign/rendering.py +192 -0
  23. sovereign/response_class.py +18 -0
  24. sovereign/server.py +93 -35
  25. sovereign/sources/file.py +1 -1
  26. sovereign/sources/inline.py +1 -0
  27. sovereign/sources/lib.py +1 -0
  28. sovereign/sources/poller.py +296 -53
  29. sovereign/statistics.py +17 -20
  30. sovereign/templates/base.html +59 -46
  31. sovereign/templates/resources.html +203 -102
  32. sovereign/testing/loaders.py +8 -0
  33. sovereign/{modifiers/test.py → testing/modifiers.py} +0 -2
  34. sovereign/tracing.py +102 -0
  35. sovereign/types.py +299 -0
  36. sovereign/utils/auth.py +26 -13
  37. sovereign/utils/crypto/__init__.py +0 -0
  38. sovereign/utils/crypto/crypto.py +135 -0
  39. sovereign/utils/crypto/suites/__init__.py +21 -0
  40. sovereign/utils/crypto/suites/aes_gcm_cipher.py +42 -0
  41. sovereign/utils/crypto/suites/base_cipher.py +21 -0
  42. sovereign/utils/crypto/suites/disabled_cipher.py +25 -0
  43. sovereign/utils/crypto/suites/fernet_cipher.py +29 -0
  44. sovereign/utils/dictupdate.py +2 -1
  45. sovereign/utils/eds.py +37 -21
  46. sovereign/utils/mock.py +54 -16
  47. sovereign/utils/resources.py +17 -0
  48. sovereign/utils/version_info.py +8 -0
  49. sovereign/views/__init__.py +4 -0
  50. sovereign/views/api.py +61 -0
  51. sovereign/views/crypto.py +46 -15
  52. sovereign/views/discovery.py +37 -116
  53. sovereign/views/healthchecks.py +87 -18
  54. sovereign/views/interface.py +112 -112
  55. sovereign/worker.py +204 -0
  56. {sovereign-0.19.3.dist-info → sovereign-1.0.0b148.dist-info}/METADATA +79 -76
  57. sovereign-1.0.0b148.dist-info/RECORD +77 -0
  58. {sovereign-0.19.3.dist-info → sovereign-1.0.0b148.dist-info}/WHEEL +1 -1
  59. sovereign-1.0.0b148.dist-info/entry_points.txt +38 -0
  60. sovereign_files/__init__.py +0 -0
  61. sovereign_files/static/darkmode.js +51 -0
  62. sovereign_files/static/node_expression.js +42 -0
  63. sovereign_files/static/panel.js +76 -0
  64. sovereign_files/static/resources.css +246 -0
  65. sovereign_files/static/resources.js +642 -0
  66. sovereign_files/static/sass/style.scss +33 -0
  67. sovereign_files/static/style.css +16143 -0
  68. sovereign_files/static/style.css.map +1 -0
  69. sovereign/config_loader.py +0 -225
  70. sovereign/discovery.py +0 -175
  71. sovereign/logs.py +0 -131
  72. sovereign/schemas.py +0 -780
  73. sovereign/static/sass/style.scss +0 -27
  74. sovereign/static/style.css +0 -13553
  75. sovereign/templates/ul_filter.html +0 -22
  76. sovereign/utils/crypto.py +0 -103
  77. sovereign/views/admin.py +0 -120
  78. sovereign-0.19.3.dist-info/LICENSE.txt +0 -13
  79. sovereign-0.19.3.dist-info/RECORD +0 -47
  80. 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.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.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
-
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
- 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 config, ConfiguredResourceTypes
11
+ from sovereign.response_class import json_response_class
17
12
  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
- )
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(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
+ import sentry_sdk
72
+ from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
84
73
 
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")
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(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())
99
+ return FileResponse(get_package_file("sovereign_files", f"static/{filename}")) # type: ignore[arg-type]
111
100
 
112
- @application.on_event("startup")
113
- async def refresh_template_context() -> None:
114
- asyncio.create_task(template_context.start_refresh_context())
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