csrd-versioning 0.1.0__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.
- csrd/versioning/__init__.py +99 -0
- csrd/versioning/_constants.py +28 -0
- csrd/versioning/_core.py +240 -0
- csrd/versioning/_dependencies.py +67 -0
- csrd/versioning/_dependency_wiring.py +272 -0
- csrd/versioning/_dispatch.py +203 -0
- csrd/versioning/_docs.py +495 -0
- csrd/versioning/_fastapi_types.py +44 -0
- csrd/versioning/_helpers.py +149 -0
- csrd/versioning/_orchestration.py +399 -0
- csrd/versioning/_redoc.py +98 -0
- csrd/versioning/_settings.py +88 -0
- csrd/versioning/_swagger_ui_version.py +12 -0
- csrd/versioning/_types.py +14 -0
- csrd/versioning/actuator/README.md +261 -0
- csrd/versioning/actuator/__init__.py +3 -0
- csrd/versioning/actuator/actuator.py +111 -0
- csrd/versioning/actuator/plugins/__init__.py +65 -0
- csrd/versioning/actuator/plugins/base.py +77 -0
- csrd/versioning/actuator/plugins/env/__init__.py +31 -0
- csrd/versioning/actuator/plugins/env/plugin.py +145 -0
- csrd/versioning/actuator/plugins/env/providers.py +105 -0
- csrd/versioning/actuator/plugins/env/registry.py +211 -0
- csrd/versioning/actuator/plugins/health/__init__.py +37 -0
- csrd/versioning/actuator/plugins/health/auto.py +244 -0
- csrd/versioning/actuator/plugins/health/indicators.py +137 -0
- csrd/versioning/actuator/plugins/health/plugin.py +271 -0
- csrd/versioning/actuator/plugins/info.py +120 -0
- csrd/versioning/actuator/tools/README.md +54 -0
- csrd/versioning/actuator/tools/__init__.py +0 -0
- csrd/versioning/actuator/tools/generate_service_info_from_git.py +126 -0
- csrd/versioning/exception_handlers.py +126 -0
- csrd/versioning/py.typed +0 -0
- csrd/versioning/swagger_plugins/__init__.py +15 -0
- csrd/versioning/swagger_plugins/_base.py +153 -0
- csrd/versioning/swagger_plugins/file_upload/__init__.py +35 -0
- csrd/versioning/swagger_plugins/file_upload/_body_factory.py +121 -0
- csrd/versioning/swagger_plugins/file_upload/_schema_patcher.py +165 -0
- csrd/versioning/swagger_plugins/file_upload/file_upload_plugin.css +181 -0
- csrd/versioning/swagger_plugins/file_upload/file_upload_plugin.js +529 -0
- csrd/versioning/templates/__init__.py +0 -0
- csrd/versioning/templates/favicon.png +0 -0
- csrd/versioning/templates/swagger_ui.css +616 -0
- csrd/versioning/templates/swagger_ui.html +43 -0
- csrd/versioning/templates/swagger_ui.js +131 -0
- csrd_versioning-0.1.0.dist-info/METADATA +15 -0
- csrd_versioning-0.1.0.dist-info/RECORD +48 -0
- csrd_versioning-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from functools import lru_cache
|
|
3
|
+
|
|
4
|
+
from pydantic import AliasChoices, Field, SecretStr
|
|
5
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class VersioningSettings(BaseSettings):
|
|
11
|
+
"""Environment-backed settings required for the versioning system to operate.
|
|
12
|
+
|
|
13
|
+
This includes identity and auth values (e.g. ``jwt_secret``) that
|
|
14
|
+
versioned endpoints depend on, not just version-routing configuration.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
model_config = SettingsConfigDict(
|
|
18
|
+
env_prefix="",
|
|
19
|
+
case_sensitive=False,
|
|
20
|
+
extra="ignore",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
app_name: str | None = Field(
|
|
24
|
+
default=None,
|
|
25
|
+
validation_alias=AliasChoices("APP_NAME", "app_name"),
|
|
26
|
+
)
|
|
27
|
+
jwt_secret: SecretStr | None = Field(
|
|
28
|
+
default=None,
|
|
29
|
+
validation_alias=AliasChoices("JWT_SECRET", "JAVELIN_JWT_KEY_CREDENTIALS_JAVELIN_JWT_KEY"),
|
|
30
|
+
)
|
|
31
|
+
service_root_domain: str | None = Field(
|
|
32
|
+
default=None,
|
|
33
|
+
validation_alias=AliasChoices("SERVICE_ROOT_DOMAIN"),
|
|
34
|
+
)
|
|
35
|
+
build_tag: str | None = Field(
|
|
36
|
+
default=None,
|
|
37
|
+
validation_alias=AliasChoices("BUILD_TAG"),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@lru_cache(maxsize=1)
|
|
42
|
+
def load_versioning_settings() -> VersioningSettings:
|
|
43
|
+
"""Return cached settings read from environment variables.
|
|
44
|
+
|
|
45
|
+
The result is cached for the lifetime of the process — only add
|
|
46
|
+
fields here for values that are fixed at startup (e.g. ``APP_NAME``,
|
|
47
|
+
``JWT_SECRET``). Environment variable changes after the first call
|
|
48
|
+
will not be reflected.
|
|
49
|
+
"""
|
|
50
|
+
return VersioningSettings()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_app_name(override: str | None = None) -> str:
|
|
54
|
+
"""Resolve application name with an explicit override or environment fallback.
|
|
55
|
+
|
|
56
|
+
When *override* is a non-empty string it is returned immediately.
|
|
57
|
+
When *override* is ``None``, the function reads ``APP_NAME`` from
|
|
58
|
+
environment-backed Pydantic settings. If ``APP_NAME`` is missing, or any
|
|
59
|
+
error occurs, the function falls back to ``"UNKNOWN_APP"``.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
ValueError: If *override* is an empty string.
|
|
63
|
+
"""
|
|
64
|
+
if isinstance(override, str):
|
|
65
|
+
if not override:
|
|
66
|
+
raise ValueError("override must be a non-empty string or None")
|
|
67
|
+
return override
|
|
68
|
+
|
|
69
|
+
fallback = "UNKNOWN_APP"
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
settings = load_versioning_settings()
|
|
73
|
+
if settings.app_name:
|
|
74
|
+
logger.debug("App name loaded from settings: %s", settings.app_name)
|
|
75
|
+
return settings.app_name
|
|
76
|
+
|
|
77
|
+
logger.warning("No APP_NAME value found in settings, defaulting to %s", fallback)
|
|
78
|
+
return fallback
|
|
79
|
+
except Exception as e:
|
|
80
|
+
logger.warning(
|
|
81
|
+
"error retrieving app_name from settings defaulting to %s: %s",
|
|
82
|
+
fallback,
|
|
83
|
+
e,
|
|
84
|
+
)
|
|
85
|
+
return fallback
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
__all__ = ("VersioningSettings", "load_app_name", "load_versioning_settings")
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Pinned Swagger UI version and SRI hashes for jsDelivr CDN assets.
|
|
2
|
+
#
|
|
3
|
+
# Auto-generated — do not edit manually.
|
|
4
|
+
# Regenerate with::
|
|
5
|
+
# python scripts/update_swagger_ui.py <version>
|
|
6
|
+
#
|
|
7
|
+
# Example:
|
|
8
|
+
# python scripts/update_swagger_ui.py 5.18.2
|
|
9
|
+
|
|
10
|
+
SWAGGER_UI_VERSION = "5.18.2"
|
|
11
|
+
SWAGGER_UI_CSS_SRI = "rcbEi6xgdPk0iWkAQzT2F3FeBJXdG+ydrawGlfHAFIZG7wU6aKbQaRewysYpmrlW"
|
|
12
|
+
SWAGGER_UI_JS_SRI = "NXtFPpN61oWCuN4D42K6Zd5Rt2+uxeIT36R7kpXBuY9tLnZorzrJ4ykpqwJfgjpZ"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
VersionKey = Enum | str | int | None
|
|
6
|
+
VersionMap = Mapping[VersionKey, Any]
|
|
7
|
+
VersionedAppState = Mapping[str, Any]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
__all__ = (
|
|
11
|
+
"VersionKey",
|
|
12
|
+
"VersionMap",
|
|
13
|
+
"VersionedAppState",
|
|
14
|
+
)
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# Actuator Extension Model
|
|
2
|
+
|
|
3
|
+
This package provides Spring Boot-style management endpoints under `/actuator`.
|
|
4
|
+
|
|
5
|
+
## Defaults
|
|
6
|
+
|
|
7
|
+
- `register_actuator_router(app)` mounts `/actuator` and auto-wires built-in plugins.
|
|
8
|
+
- `/_info` and `/_info/health` are separate versioning introspection endpoints and remain unchanged.
|
|
9
|
+
- Consumers can pass plugin overrides via `configure_versioned_api(..., actuator_plugins=[...])`
|
|
10
|
+
or `create_versioned_app(..., actuator_plugins=[...])`. On name conflicts,
|
|
11
|
+
consumer plugins override built-in plugins.
|
|
12
|
+
|
|
13
|
+
## Plugin Contract
|
|
14
|
+
|
|
15
|
+
Implement the `ActuatorPlugin` protocol from `plugins/base.py`:
|
|
16
|
+
|
|
17
|
+
- `name`: short plugin identifier
|
|
18
|
+
- `register(router, app=..., prefix=...)`: add routes and return link metadata
|
|
19
|
+
|
|
20
|
+
The returned link metadata is merged into the root payload:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"_links": {
|
|
25
|
+
"self": {"href": ".../actuator", "templated": false}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
When the info plugin is active and the metadata file exists, the root
|
|
31
|
+
payload also includes an `info` link.
|
|
32
|
+
|
|
33
|
+
When the env plugin is active, the root payload includes an `env` link.
|
|
34
|
+
When the env plugin is active, the root payload also includes a templated
|
|
35
|
+
`env-toMatch` link.
|
|
36
|
+
|
|
37
|
+
## Built-in Plugin
|
|
38
|
+
|
|
39
|
+
`InfoActuatorPlugin` serves `/actuator/info` by reading JSON metadata from:
|
|
40
|
+
|
|
41
|
+
1. `ACTUATOR_INFO_PATH` environment variable
|
|
42
|
+
2. `service_info.json` (default, relative to the process working directory)
|
|
43
|
+
|
|
44
|
+
If the resolved file does not exist, `/actuator/info` is not registered.
|
|
45
|
+
If the file exists but is invalid JSON, the endpoint returns `{}`.
|
|
46
|
+
|
|
47
|
+
`EnvActuatorPlugin` serves `/actuator/env` with runtime profile metadata:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"activeProfiles": ["dev", "stage"],
|
|
52
|
+
"defaultProfiles": [],
|
|
53
|
+
"propertySources": []
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Profiles are read from `SPRING_PROFILES_ACTIVE` as a comma-separated list.
|
|
58
|
+
|
|
59
|
+
`EnvActuatorPlugin` also serves `/actuator/env/{toMatch}` and filters
|
|
60
|
+
environment variables by case-insensitive key match into `propertySources`.
|
|
61
|
+
|
|
62
|
+
`HealthActuatorPlugin` serves `/actuator/health` with composable health
|
|
63
|
+
indicators:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"status": "UP",
|
|
68
|
+
"groups": ["liveness", "readiness"],
|
|
69
|
+
"components": {
|
|
70
|
+
"ping": {"status": "UP"},
|
|
71
|
+
"diskSpace": {"status": "UP", "details": {"total": ..., "free": ..., "threshold": 10485760, "path": ".", "exists": true}},
|
|
72
|
+
"livenessState": {"status": "UP"},
|
|
73
|
+
"readinessState": {"status": "UP"}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Default indicators: `PingHealthIndicator`, `DiskSpaceHealthIndicator`,
|
|
79
|
+
`LivenessIndicator`, `ReadinessIndicator`.
|
|
80
|
+
|
|
81
|
+
Default health groups:
|
|
82
|
+
- `liveness` → `["livenessState"]`
|
|
83
|
+
- `readiness` → `["readinessState", "ping", "diskSpace"]`
|
|
84
|
+
|
|
85
|
+
`/actuator/health/{component}` returns an individual indicator's status.
|
|
86
|
+
`/actuator/health/{group}` returns the aggregated status for a health group.
|
|
87
|
+
|
|
88
|
+
### Custom Indicators
|
|
89
|
+
|
|
90
|
+
Extend `BaseHealthIndicator` for exception-safe custom indicators.
|
|
91
|
+
Override `_check()` — the base class wraps it in try/except automatically:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from api_versioning.fastapi.versioning.actuator.plugins import BaseHealthIndicator
|
|
95
|
+
|
|
96
|
+
class MyDatabaseIndicator(BaseHealthIndicator):
|
|
97
|
+
_name = "db"
|
|
98
|
+
|
|
99
|
+
def __init__(self, pool):
|
|
100
|
+
self._pool = pool
|
|
101
|
+
|
|
102
|
+
def _check(self):
|
|
103
|
+
self._pool.execute("SELECT 1")
|
|
104
|
+
return {"status": "UP"}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Both sync and async `_check()` are supported:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
class MyAsyncIndicator(BaseHealthIndicator):
|
|
111
|
+
_name = "myService"
|
|
112
|
+
|
|
113
|
+
async def _check(self):
|
|
114
|
+
result = await some_client.ping()
|
|
115
|
+
return {"status": "UP", "details": result}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Register via `HealthActuatorPlugin.with_indicators()`:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
HealthActuatorPlugin.with_indicators(db=MyDatabaseIndicator(pool))
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Auto-Discovery
|
|
125
|
+
|
|
126
|
+
When `auto_discover=True` (the default), the health plugin inspects
|
|
127
|
+
`app.state` on each request and registers indicators for detected services:
|
|
128
|
+
|
|
129
|
+
| Indicator | `app.state` key | Source package |
|
|
130
|
+
|------------------|-----------------------------------|-------------------------------------|
|
|
131
|
+
| `rabbit` | `rabbit` | RabbitMQ client |
|
|
132
|
+
| `kafka` | `kafka_producer_manager` / `kafka_consumer_manager` | Kafka client |
|
|
133
|
+
| `mongo` | `mongo_client` | MongoDB client |
|
|
134
|
+
| `launchDarkly` | `launchdarkly_client` | LaunchDarkly client |
|
|
135
|
+
|
|
136
|
+
Auto-discovered indicators are appended to the `readiness` group.
|
|
137
|
+
Consumer-provided indicators with the same name take precedence.
|
|
138
|
+
|
|
139
|
+
To build custom `app.state`-based indicators, extend `AppStateHealthIndicator`
|
|
140
|
+
which adds `_get_state(key)` and `_require_attr(obj, attr)` helpers with
|
|
141
|
+
one-time attribute-missing warnings:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from api_versioning.fastapi.versioning.actuator.plugins import AppStateHealthIndicator
|
|
145
|
+
|
|
146
|
+
class ElasticHealthIndicator(AppStateHealthIndicator):
|
|
147
|
+
_name = "elastic"
|
|
148
|
+
|
|
149
|
+
def _check(self):
|
|
150
|
+
client = self._get_state("elastic_client")
|
|
151
|
+
if client is None:
|
|
152
|
+
return {"status": "DOWN", "details": {"error": "State removed"}}
|
|
153
|
+
ok, ping = self._require_attr(client, "ping")
|
|
154
|
+
if not ok:
|
|
155
|
+
return {"status": "DOWN", "details": {"error": "No ping method"}}
|
|
156
|
+
ping()
|
|
157
|
+
return {"status": "UP"}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Disable auto-discovery with `auto_discover=False`:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
HealthActuatorPlugin(auto_discover=False)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Indicator Class Hierarchy
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
HealthIndicator (Protocol — duck-typing contract)
|
|
170
|
+
│
|
|
171
|
+
BaseHealthIndicator (exception-safe template method)
|
|
172
|
+
├── PingHealthIndicator
|
|
173
|
+
├── LivenessIndicator
|
|
174
|
+
├── ReadinessIndicator
|
|
175
|
+
├── DiskSpaceHealthIndicator
|
|
176
|
+
│
|
|
177
|
+
└── AppStateHealthIndicator (adds app.state helpers + attribute guards)
|
|
178
|
+
├── RabbitHealthIndicator
|
|
179
|
+
├── KafkaHealthIndicator
|
|
180
|
+
├── MongoHealthIndicator
|
|
181
|
+
└── LaunchDarklyHealthIndicator
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### ShowDetails
|
|
185
|
+
|
|
186
|
+
`ShowDetails` controls component visibility:
|
|
187
|
+
|
|
188
|
+
- `ALWAYS` (default) – include `components` with per-indicator details
|
|
189
|
+
- `NEVER` – only return `{"status": "UP"}`
|
|
190
|
+
- `WHEN_AUTHORIZED` – reserved; currently behaves like `NEVER`
|
|
191
|
+
|
|
192
|
+
Build-time generation patterns (including Docker multi-stage examples) live in
|
|
193
|
+
`tools/README.md`.
|
|
194
|
+
|
|
195
|
+
## Property Sources
|
|
196
|
+
|
|
197
|
+
`EnvActuatorPlugin` uses a `PropertySourceRegistry` to collect environment
|
|
198
|
+
metadata from composable providers.
|
|
199
|
+
|
|
200
|
+
Built-in providers (registered when `with_defaults=True`):
|
|
201
|
+
|
|
202
|
+
- `SystemEnvironmentProvider` – `os.environ` snapshot
|
|
203
|
+
- `SystemPropertiesProvider` – Python runtime info (`sys.version`, platform, etc.)
|
|
204
|
+
- `DockerConfigProvider` – Docker-related env vars (when present)
|
|
205
|
+
|
|
206
|
+
All values are wrapped in `{"value": ...}` dicts, matching Spring Boot format.
|
|
207
|
+
|
|
208
|
+
### Custom Providers
|
|
209
|
+
|
|
210
|
+
Implement the `PropertySourceProvider` protocol:
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
class PropertySourceProvider(Protocol):
|
|
214
|
+
def get_sources(self, to_match: str | None = None) -> list[dict[str, Any]]: ...
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Register via `EnvActuatorPlugin.with_providers()`:
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
EnvActuatorPlugin.with_providers(
|
|
221
|
+
myFeatures=MyFeatureProvider(),
|
|
222
|
+
)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Auto-Wiring
|
|
226
|
+
|
|
227
|
+
Plugins that extend `BaseActuatorPlugin` can contribute property source
|
|
228
|
+
providers via `register_property_source_provider(name=..., provider=...)`.
|
|
229
|
+
During plugin resolution, `_wire_env_property_sources()` collects all
|
|
230
|
+
contributions and injects them into the env plugin automatically.
|
|
231
|
+
|
|
232
|
+
## Sanitization
|
|
233
|
+
|
|
234
|
+
Property values are redacted by default. Control behavior with
|
|
235
|
+
`SanitizationConfig`:
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
from api_versioning.fastapi.versioning.actuator.plugins import (
|
|
239
|
+
EnvActuatorPlugin, SanitizationConfig, ShowValues,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
EnvActuatorPlugin.with_providers(
|
|
243
|
+
sanitization=SanitizationConfig(show_values=ShowValues.ALWAYS),
|
|
244
|
+
)
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`ShowValues` modes:
|
|
248
|
+
|
|
249
|
+
- `NEVER` (default) – redact **all** values to `"******"`
|
|
250
|
+
- `ALWAYS` – show benign values; sensitive keys still redacted
|
|
251
|
+
- `WHEN_AUTHORIZED` – reserved; currently behaves like `NEVER` (safe default until auth is wired)
|
|
252
|
+
|
|
253
|
+
Sensitive keys matched by default: `password`, `secret`, `key`, `token`,
|
|
254
|
+
`.*credentials.*`, `vcap_services`. Extend with `additional_keys_to_sanitize`
|
|
255
|
+
or replace entirely with `keys_to_sanitize`.
|
|
256
|
+
|
|
257
|
+
## Adding a Plugin
|
|
258
|
+
|
|
259
|
+
1. Add a module under `plugins/`.
|
|
260
|
+
2. Implement `ActuatorPlugin`.
|
|
261
|
+
3. Add it to `default_actuator_plugins()` in `plugins/__init__.py`.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Spring Boot-style ``/actuator`` management endpoints for FastAPI."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, FastAPI, Request
|
|
7
|
+
|
|
8
|
+
from .plugins import ActuatorLink, ActuatorPlugin, default_actuator_plugins
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register_actuator_router(
|
|
14
|
+
app: FastAPI,
|
|
15
|
+
*,
|
|
16
|
+
plugins: Sequence[ActuatorPlugin] | None = None,
|
|
17
|
+
prefix: str = "/actuator",
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Register actuator routes directly on *app*."""
|
|
20
|
+
router = APIRouter(prefix=prefix, tags=["Actuator"], include_in_schema=False)
|
|
21
|
+
|
|
22
|
+
plugin_links = _register_plugins(
|
|
23
|
+
router,
|
|
24
|
+
app=app,
|
|
25
|
+
prefix=prefix,
|
|
26
|
+
plugins=_resolve_plugins(plugins),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@router.get("", include_in_schema=False)
|
|
30
|
+
async def actuator_root(
|
|
31
|
+
request: Request,
|
|
32
|
+
) -> dict[str, dict[str, dict[str, bool | str]]]:
|
|
33
|
+
base = str(request.base_url).rstrip("/")
|
|
34
|
+
links: dict[str, dict[str, bool | str]] = {
|
|
35
|
+
"self": {"href": f"{base}{prefix}", "templated": False}
|
|
36
|
+
}
|
|
37
|
+
for name, link in plugin_links.items():
|
|
38
|
+
links[name] = {
|
|
39
|
+
"href": f"{base}{link.href}",
|
|
40
|
+
"templated": link.templated,
|
|
41
|
+
}
|
|
42
|
+
return {"_links": links}
|
|
43
|
+
|
|
44
|
+
app.include_router(router)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _register_plugins(
|
|
48
|
+
router: APIRouter,
|
|
49
|
+
*,
|
|
50
|
+
app: FastAPI,
|
|
51
|
+
prefix: str,
|
|
52
|
+
plugins: Sequence[ActuatorPlugin],
|
|
53
|
+
) -> dict[str, ActuatorLink]:
|
|
54
|
+
links: dict[str, ActuatorLink] = {}
|
|
55
|
+
for plugin in plugins:
|
|
56
|
+
plugin_links = plugin.register(router, app=app, prefix=prefix)
|
|
57
|
+
_merge_plugin_links(links, plugin_links)
|
|
58
|
+
return links
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _merge_plugin_links(
|
|
62
|
+
links: dict[str, ActuatorLink],
|
|
63
|
+
plugin_links: Mapping[str, ActuatorLink],
|
|
64
|
+
) -> None:
|
|
65
|
+
for name, link in plugin_links.items():
|
|
66
|
+
links[name] = link
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resolve_plugins(
|
|
70
|
+
plugins: Sequence[ActuatorPlugin] | None,
|
|
71
|
+
) -> tuple[ActuatorPlugin, ...]:
|
|
72
|
+
resolved: dict[str, ActuatorPlugin] = {
|
|
73
|
+
plugin.name: plugin for plugin in default_actuator_plugins()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if plugins is not None:
|
|
77
|
+
for plugin in plugins:
|
|
78
|
+
if plugin.name in resolved:
|
|
79
|
+
logger.debug("Actuator plugin '%s' overridden by consumer", plugin.name)
|
|
80
|
+
resolved[plugin.name] = plugin
|
|
81
|
+
|
|
82
|
+
_wire_env_property_sources(resolved)
|
|
83
|
+
|
|
84
|
+
return tuple(resolved.values())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _wire_env_property_sources(resolved: dict[str, ActuatorPlugin]) -> None:
|
|
88
|
+
contributed: dict[str, object] = {}
|
|
89
|
+
|
|
90
|
+
for plugin in resolved.values():
|
|
91
|
+
providers_getter = getattr(plugin, "property_source_providers", None)
|
|
92
|
+
if callable(providers_getter):
|
|
93
|
+
providers = providers_getter()
|
|
94
|
+
if isinstance(providers, Mapping):
|
|
95
|
+
contributed.update(providers)
|
|
96
|
+
|
|
97
|
+
if not contributed:
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
env_plugin = resolved.get("env")
|
|
101
|
+
if env_plugin is None:
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
register_providers = getattr(env_plugin, "register_property_source_providers", None)
|
|
105
|
+
if callable(register_providers):
|
|
106
|
+
register_providers(contributed)
|
|
107
|
+
logger.debug(
|
|
108
|
+
"Auto-wired %d property source provider(s) into env plugin: %s",
|
|
109
|
+
len(contributed),
|
|
110
|
+
", ".join(contributed),
|
|
111
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
|
|
3
|
+
from .base import ActuatorLink, ActuatorPlugin, BaseActuatorPlugin
|
|
4
|
+
from .env import (
|
|
5
|
+
EnvActuatorPlugin,
|
|
6
|
+
PropertySourceProvider,
|
|
7
|
+
PropertySourceRegistry,
|
|
8
|
+
SanitizationConfig,
|
|
9
|
+
ShowValues,
|
|
10
|
+
create_property_source_registry,
|
|
11
|
+
)
|
|
12
|
+
from .health import (
|
|
13
|
+
AppStateHealthIndicator,
|
|
14
|
+
BaseHealthIndicator,
|
|
15
|
+
DiskSpaceHealthIndicator,
|
|
16
|
+
HealthActuatorPlugin,
|
|
17
|
+
HealthIndicator,
|
|
18
|
+
KafkaHealthIndicator,
|
|
19
|
+
LaunchDarklyHealthIndicator,
|
|
20
|
+
LivenessIndicator,
|
|
21
|
+
MongoHealthIndicator,
|
|
22
|
+
PingHealthIndicator,
|
|
23
|
+
RabbitHealthIndicator,
|
|
24
|
+
ReadinessIndicator,
|
|
25
|
+
ShowDetails,
|
|
26
|
+
Status,
|
|
27
|
+
discover_indicators,
|
|
28
|
+
)
|
|
29
|
+
from .info import InfoActuatorPlugin
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def default_actuator_plugins() -> Sequence[ActuatorPlugin]:
|
|
33
|
+
"""Return the built-in actuator plugins auto-wired by default."""
|
|
34
|
+
|
|
35
|
+
return (InfoActuatorPlugin(), EnvActuatorPlugin(), HealthActuatorPlugin())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__all__ = (
|
|
39
|
+
"ActuatorLink",
|
|
40
|
+
"ActuatorPlugin",
|
|
41
|
+
"AppStateHealthIndicator",
|
|
42
|
+
"BaseActuatorPlugin",
|
|
43
|
+
"BaseHealthIndicator",
|
|
44
|
+
"DiskSpaceHealthIndicator",
|
|
45
|
+
"EnvActuatorPlugin",
|
|
46
|
+
"HealthActuatorPlugin",
|
|
47
|
+
"HealthIndicator",
|
|
48
|
+
"InfoActuatorPlugin",
|
|
49
|
+
"KafkaHealthIndicator",
|
|
50
|
+
"LaunchDarklyHealthIndicator",
|
|
51
|
+
"LivenessIndicator",
|
|
52
|
+
"MongoHealthIndicator",
|
|
53
|
+
"PingHealthIndicator",
|
|
54
|
+
"PropertySourceProvider",
|
|
55
|
+
"PropertySourceRegistry",
|
|
56
|
+
"RabbitHealthIndicator",
|
|
57
|
+
"ReadinessIndicator",
|
|
58
|
+
"SanitizationConfig",
|
|
59
|
+
"ShowDetails",
|
|
60
|
+
"ShowValues",
|
|
61
|
+
"Status",
|
|
62
|
+
"create_property_source_registry",
|
|
63
|
+
"default_actuator_plugins",
|
|
64
|
+
"discover_indicators",
|
|
65
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from collections.abc import Callable, Mapping
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any, Protocol
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, FastAPI
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class ActuatorLink:
|
|
10
|
+
"""Link metadata rendered by the actuator root endpoint."""
|
|
11
|
+
|
|
12
|
+
href: str
|
|
13
|
+
templated: bool = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ActuatorPlugin(Protocol):
|
|
17
|
+
"""Contract for actuator plugins that register routes and expose links."""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
|
|
21
|
+
def register(
|
|
22
|
+
self,
|
|
23
|
+
router: APIRouter,
|
|
24
|
+
*,
|
|
25
|
+
app: FastAPI,
|
|
26
|
+
prefix: str,
|
|
27
|
+
) -> Mapping[str, ActuatorLink]:
|
|
28
|
+
"""Register endpoints on *router* and return link entries."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BaseActuatorPlugin:
|
|
32
|
+
"""Shared helpers for actuator plugins.
|
|
33
|
+
|
|
34
|
+
Provides optional in-memory memoization of endpoint payloads and a
|
|
35
|
+
consistent link-construction helper.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, *, cache_enabled: bool = True) -> None:
|
|
39
|
+
self._cache_enabled = cache_enabled
|
|
40
|
+
self._payload_cache: dict[str, dict[str, Any]] = {}
|
|
41
|
+
self._property_source_providers: dict[str, Any] = {}
|
|
42
|
+
|
|
43
|
+
def memoized_payload(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
cache_key: str,
|
|
47
|
+
loader: Callable[[], dict[str, Any]],
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
if not self._cache_enabled:
|
|
50
|
+
return loader()
|
|
51
|
+
|
|
52
|
+
cached = self._payload_cache.get(cache_key)
|
|
53
|
+
if cached is not None:
|
|
54
|
+
return cached
|
|
55
|
+
|
|
56
|
+
payload = loader()
|
|
57
|
+
self._payload_cache[cache_key] = payload
|
|
58
|
+
return payload
|
|
59
|
+
|
|
60
|
+
def clear_payload_cache(self) -> None:
|
|
61
|
+
self._payload_cache.clear()
|
|
62
|
+
|
|
63
|
+
def register_property_source_provider(self, *, name: str, provider: Any) -> None:
|
|
64
|
+
"""Register a property source provider contribution from this plugin.
|
|
65
|
+
|
|
66
|
+
Contributions are auto-wired into the env actuator plugin when
|
|
67
|
+
``register_actuator_router`` resolves the plugin set.
|
|
68
|
+
"""
|
|
69
|
+
self._property_source_providers[name] = provider
|
|
70
|
+
|
|
71
|
+
def property_source_providers(self) -> Mapping[str, Any]:
|
|
72
|
+
"""Return this plugin's property source provider contributions."""
|
|
73
|
+
return dict(self._property_source_providers)
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def build_link(*, prefix: str, route_path: str) -> ActuatorLink:
|
|
77
|
+
return ActuatorLink(href=f"{prefix}{route_path}")
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Spring Boot-style env actuator with property source registry."""
|
|
2
|
+
|
|
3
|
+
from .plugin import EnvActuatorPlugin
|
|
4
|
+
from .providers import (
|
|
5
|
+
DockerConfigProvider,
|
|
6
|
+
PropertySourceProvider,
|
|
7
|
+
SystemEnvironmentProvider,
|
|
8
|
+
SystemPropertiesProvider,
|
|
9
|
+
)
|
|
10
|
+
from .registry import (
|
|
11
|
+
DEFAULT_KEYS_TO_SANITIZE,
|
|
12
|
+
REDACTED_VALUE,
|
|
13
|
+
PropertySourceRegistry,
|
|
14
|
+
SanitizationConfig,
|
|
15
|
+
ShowValues,
|
|
16
|
+
create_property_source_registry,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = (
|
|
20
|
+
"DEFAULT_KEYS_TO_SANITIZE",
|
|
21
|
+
"REDACTED_VALUE",
|
|
22
|
+
"DockerConfigProvider",
|
|
23
|
+
"EnvActuatorPlugin",
|
|
24
|
+
"PropertySourceProvider",
|
|
25
|
+
"PropertySourceRegistry",
|
|
26
|
+
"SanitizationConfig",
|
|
27
|
+
"ShowValues",
|
|
28
|
+
"SystemEnvironmentProvider",
|
|
29
|
+
"SystemPropertiesProvider",
|
|
30
|
+
"create_property_source_registry",
|
|
31
|
+
)
|