sie-config 0.8.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.
- sie_config/__init__.py +1 -0
- sie_config/app_factory.py +249 -0
- sie_config/cli.py +125 -0
- sie_config/config_api.py +1645 -0
- sie_config/config_store.py +218 -0
- sie_config/health.py +27 -0
- sie_config/managed_metrics.py +431 -0
- sie_config/metrics.py +72 -0
- sie_config/model_registry.py +1796 -0
- sie_config/nats_publisher.py +382 -0
- sie_config/types.py +58 -0
- sie_config/version.py +12 -0
- sie_config-0.8.0.dist-info/METADATA +22 -0
- sie_config-0.8.0.dist-info/RECORD +16 -0
- sie_config-0.8.0.dist-info/WHEEL +4 -0
- sie_config-0.8.0.dist-info/entry_points.txt +2 -0
sie_config/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import AsyncGenerator, Callable
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI, Request
|
|
10
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
11
|
+
from starlette.responses import Response
|
|
12
|
+
|
|
13
|
+
from sie_config import metrics as sie_metrics
|
|
14
|
+
from sie_config.config_api import router as config_router
|
|
15
|
+
from sie_config.config_store import ConfigStore
|
|
16
|
+
from sie_config.health import router as health_router
|
|
17
|
+
from sie_config.managed_metrics import setup_managed_metrics
|
|
18
|
+
from sie_config.model_registry import ModelRegistry
|
|
19
|
+
from sie_config.nats_publisher import NatsPublisher
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# Default paths for bundle and model configs.
|
|
24
|
+
# In Docker, SIE_BUNDLES_DIR and SIE_MODELS_DIR are always set (see Dockerfile).
|
|
25
|
+
# In development, fall back to the sibling sie_server package in the source tree.
|
|
26
|
+
_DEFAULT_BUNDLES_DIR = Path(__file__).parent.parent.parent.parent / "sie_server" / "bundles"
|
|
27
|
+
_DEFAULT_MODELS_DIR = Path(__file__).parent.parent.parent.parent / "sie_server" / "models"
|
|
28
|
+
_BOUNDED_HTTP_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _TelemetryHTTPMiddleware(BaseHTTPMiddleware):
|
|
32
|
+
# Record one backend-neutral config HTTP observation for every request.
|
|
33
|
+
#
|
|
34
|
+
# The `path` label is the FastAPI route template (e.g.
|
|
35
|
+
# `/v1/configs/models/{model_id}`) rather than the raw URL so
|
|
36
|
+
# per-model reads do not explode the label cardinality. We resolve
|
|
37
|
+
# the route after the downstream handler runs -- Starlette only
|
|
38
|
+
# populates `request.scope["route"]` once routing has matched. Unknown
|
|
39
|
+
# URLs collapse to the contract's bounded `other` label; the Modal URL is
|
|
40
|
+
# externally reachable, so retaining arbitrary request paths would let a
|
|
41
|
+
# caller create unbounded time series.
|
|
42
|
+
#
|
|
43
|
+
# The exception path matters as much as the success one. If
|
|
44
|
+
# `call_next(...)` raises, Starlette converts the exception into a
|
|
45
|
+
# 500 *outside* this middleware, so a naive `response = await
|
|
46
|
+
# call_next(request)` followed by `.labels(...)` would miss exactly
|
|
47
|
+
# the failures operators care about most. We wrap the entire
|
|
48
|
+
# critical section in `try / except / finally` so that:
|
|
49
|
+
# - a raised exception still bumps `status="500"` in the counter
|
|
50
|
+
# and observes latency (the timing is meaningful -- a 50 ms
|
|
51
|
+
# crash is very different from a 30 s one),
|
|
52
|
+
# - the original exception is re-raised so Starlette's default
|
|
53
|
+
# 500 handler runs and the client sees the same response it
|
|
54
|
+
# would without this middleware.
|
|
55
|
+
|
|
56
|
+
async def dispatch(
|
|
57
|
+
self,
|
|
58
|
+
request: Request,
|
|
59
|
+
call_next: RequestResponseEndpoint,
|
|
60
|
+
) -> Response:
|
|
61
|
+
# `status_code` is seeded at 500 so an exception raised
|
|
62
|
+
# inside `call_next` (before we can read `response.status_code`)
|
|
63
|
+
# still attributes the failure correctly in the `finally` block.
|
|
64
|
+
# FastAPI/Starlette's outer exception handler turns the raised
|
|
65
|
+
# exception into a 500 for the client, so counting it as a 500
|
|
66
|
+
# matches what the caller actually sees.
|
|
67
|
+
start = time.monotonic()
|
|
68
|
+
status_code = 500
|
|
69
|
+
try:
|
|
70
|
+
response = await call_next(request)
|
|
71
|
+
status_code = response.status_code
|
|
72
|
+
return response
|
|
73
|
+
finally:
|
|
74
|
+
elapsed = time.monotonic() - start
|
|
75
|
+
route = request.scope.get("route")
|
|
76
|
+
path_label = getattr(route, "path", None) or "other"
|
|
77
|
+
method_label = request.method if request.method in _BOUNDED_HTTP_METHODS else "other"
|
|
78
|
+
|
|
79
|
+
sie_metrics.record_http_request(
|
|
80
|
+
method=method_label,
|
|
81
|
+
path=path_label,
|
|
82
|
+
status=status_code,
|
|
83
|
+
duration_s=elapsed,
|
|
84
|
+
)
|
|
85
|
+
if path_label == "/v1/configs/export":
|
|
86
|
+
sie_metrics.record_snapshot_publish(success=200 <= status_code < 300)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class AppFactory:
|
|
90
|
+
"""Factory for creating the SIE Config Service FastAPI application."""
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def create_app(cls) -> FastAPI:
|
|
94
|
+
"""Create and configure the FastAPI application.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Configured FastAPI application instance.
|
|
98
|
+
"""
|
|
99
|
+
setup_managed_metrics()
|
|
100
|
+
app = FastAPI(
|
|
101
|
+
title="SIE Config Service",
|
|
102
|
+
description="Config control plane for SIE clusters",
|
|
103
|
+
version="0.1.0",
|
|
104
|
+
lifespan=cls._create_lifespan(),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Do not install request instrumentation at all when telemetry is
|
|
108
|
+
# disabled. A no-op OTel meter still leaves clock reads, route
|
|
109
|
+
# resolution and attribute preparation on every request; omitting the
|
|
110
|
+
# middleware makes the public disabled path a true pass-through.
|
|
111
|
+
#
|
|
112
|
+
# When enabled, telemetry must wrap the app BEFORE the routers mount
|
|
113
|
+
# so it observes every request (including errors raised before a route
|
|
114
|
+
# matches, e.g. body-size rejections). Starlette applies middleware in
|
|
115
|
+
# outer-to-inner order, so adding it first makes it the outermost layer.
|
|
116
|
+
if sie_metrics.telemetry_enabled():
|
|
117
|
+
app.add_middleware(_TelemetryHTTPMiddleware)
|
|
118
|
+
|
|
119
|
+
app.include_router(health_router)
|
|
120
|
+
app.include_router(config_router)
|
|
121
|
+
|
|
122
|
+
return app
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def _create_lifespan(cls) -> Callable[[FastAPI], Any]:
|
|
126
|
+
"""Create the lifespan context manager for the application."""
|
|
127
|
+
|
|
128
|
+
@asynccontextmanager
|
|
129
|
+
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
130
|
+
"""Application lifespan manager."""
|
|
131
|
+
logger.info("Starting SIE Config Service")
|
|
132
|
+
|
|
133
|
+
async with (
|
|
134
|
+
cls._model_registry(app),
|
|
135
|
+
cls._config_store(app),
|
|
136
|
+
cls._nats_publisher(app),
|
|
137
|
+
):
|
|
138
|
+
yield
|
|
139
|
+
|
|
140
|
+
logger.info("Stopped SIE Config Service")
|
|
141
|
+
|
|
142
|
+
return lifespan
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
@asynccontextmanager
|
|
146
|
+
async def _model_registry(cls, app: FastAPI) -> AsyncGenerator[None, None]:
|
|
147
|
+
"""Initialize ModelRegistry for model->bundle mapping."""
|
|
148
|
+
bundles_dir = Path(os.environ.get("SIE_BUNDLES_DIR", str(_DEFAULT_BUNDLES_DIR)))
|
|
149
|
+
models_dir = Path(os.environ.get("SIE_MODELS_DIR", str(_DEFAULT_MODELS_DIR)))
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
model_registry = ModelRegistry(bundles_dir, models_dir)
|
|
153
|
+
app.state.model_registry = model_registry
|
|
154
|
+
unrouteable = model_registry.unrouteable_models
|
|
155
|
+
logger.info(
|
|
156
|
+
"ModelRegistry initialized: %d bundles, %d models (%d unrouteable)",
|
|
157
|
+
len(model_registry.list_bundles()),
|
|
158
|
+
len(model_registry.list_models()),
|
|
159
|
+
len(unrouteable),
|
|
160
|
+
)
|
|
161
|
+
# Seed the models gauge. At this point every model came
|
|
162
|
+
# from disk; the `api`-sourced tally catches up once
|
|
163
|
+
# ConfigStore restore runs in `_config_store`.
|
|
164
|
+
sie_metrics.update_models_gauge(
|
|
165
|
+
api_count=0,
|
|
166
|
+
filesystem_count=len(model_registry.list_models()),
|
|
167
|
+
)
|
|
168
|
+
except Exception:
|
|
169
|
+
logger.exception("Failed to initialize ModelRegistry, continuing without it")
|
|
170
|
+
app.state.model_registry = None
|
|
171
|
+
|
|
172
|
+
yield
|
|
173
|
+
|
|
174
|
+
@classmethod
|
|
175
|
+
@asynccontextmanager
|
|
176
|
+
async def _config_store(cls, app: FastAPI) -> AsyncGenerator[None, None]:
|
|
177
|
+
"""Initialize config store for persisting API-added model configs."""
|
|
178
|
+
config_dir = os.environ.get("SIE_CONFIG_STORE_DIR")
|
|
179
|
+
if config_dir:
|
|
180
|
+
store = ConfigStore(config_dir)
|
|
181
|
+
app.state.config_store = store
|
|
182
|
+
initial_epoch = store.read_epoch()
|
|
183
|
+
logger.info("Config store initialized at %s (epoch=%d)", config_dir, initial_epoch)
|
|
184
|
+
# Mirror the persisted epoch into telemetry so the
|
|
185
|
+
# `sie.config.epoch` gauge reflects reality immediately on
|
|
186
|
+
# startup. Without this, dashboards would read 0 until the
|
|
187
|
+
# first successful `POST /v1/configs/models` call bumped
|
|
188
|
+
# the counter — which, crucially, never happens in a
|
|
189
|
+
# read-only control plane.
|
|
190
|
+
sie_metrics.set_epoch(initial_epoch)
|
|
191
|
+
|
|
192
|
+
if os.environ.get("SIE_CONFIG_RESTORE", "").lower() == "true":
|
|
193
|
+
model_registry: ModelRegistry | None = app.state.model_registry
|
|
194
|
+
if model_registry is None:
|
|
195
|
+
logger.warning("Cannot restore configs -- ModelRegistry not initialized")
|
|
196
|
+
else:
|
|
197
|
+
stored_models = store.load_all_models()
|
|
198
|
+
for model_id, model_config in stored_models.items():
|
|
199
|
+
try:
|
|
200
|
+
model_registry.add_model_config(model_config)
|
|
201
|
+
logger.info("Restored model from config store: %s", model_id)
|
|
202
|
+
except Exception:
|
|
203
|
+
logger.exception("Failed to restore model: %s", model_id)
|
|
204
|
+
if stored_models:
|
|
205
|
+
logger.info("Restored %d models from config store", len(stored_models))
|
|
206
|
+
# Recompute the split now that API-added models
|
|
207
|
+
# have been folded back into the registry.
|
|
208
|
+
api_count = len(store.list_models())
|
|
209
|
+
total = len(model_registry.list_models())
|
|
210
|
+
sie_metrics.update_models_gauge(
|
|
211
|
+
api_count=api_count,
|
|
212
|
+
filesystem_count=max(total - api_count, 0),
|
|
213
|
+
)
|
|
214
|
+
else:
|
|
215
|
+
app.state.config_store = None
|
|
216
|
+
# Epoch zero is authoritative in no-store deployments. Seed the
|
|
217
|
+
# synchronous gauge explicitly so dashboards distinguish that
|
|
218
|
+
# state from a producer that never emitted telemetry.
|
|
219
|
+
sie_metrics.set_epoch(0)
|
|
220
|
+
|
|
221
|
+
yield
|
|
222
|
+
|
|
223
|
+
@classmethod
|
|
224
|
+
@asynccontextmanager
|
|
225
|
+
async def _nats_publisher(cls, app: FastAPI) -> AsyncGenerator[None, None]:
|
|
226
|
+
"""Initialize NATS publisher for config distribution."""
|
|
227
|
+
nats_url = os.environ.get("SIE_NATS_URL")
|
|
228
|
+
nats_publisher: NatsPublisher | None = None
|
|
229
|
+
|
|
230
|
+
# The asynchronous dial may take the full startup retry budget, and a
|
|
231
|
+
# deployment without NATS never dials at all. In both cases false is an
|
|
232
|
+
# authoritative current value rather than an absent series.
|
|
233
|
+
sie_metrics.set_nats_connected(False)
|
|
234
|
+
|
|
235
|
+
if nats_url:
|
|
236
|
+
nats_publisher = NatsPublisher(nats_url=nats_url)
|
|
237
|
+
app.state.nats_publisher = nats_publisher
|
|
238
|
+
# Do not await NATS here: it can outlast startup probe budgets while the
|
|
239
|
+
# NATS pod schedules; /healthz must bind as soon as model registry + store init finish.
|
|
240
|
+
nats_publisher.kickoff_connect()
|
|
241
|
+
else:
|
|
242
|
+
app.state.nats_publisher = None
|
|
243
|
+
logger.info("NATS not configured (SIE_NATS_URL not set) -- config distribution disabled")
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
yield
|
|
247
|
+
finally:
|
|
248
|
+
if nats_publisher:
|
|
249
|
+
await nats_publisher.disconnect()
|
sie_config/cli.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import logging
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from typing import Annotated, Any
|
|
6
|
+
|
|
7
|
+
import orjson
|
|
8
|
+
import typer
|
|
9
|
+
import uvicorn
|
|
10
|
+
|
|
11
|
+
from sie_config.types import AuditEntry
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
name="sie-config",
|
|
15
|
+
help="SIE Config Service - Config control plane for SIE clusters",
|
|
16
|
+
no_args_is_help=True,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Derived from AuditEntry dataclass -- single source of truth for audit fields.
|
|
21
|
+
OPTIONAL_FIELDS = tuple(f.name for f in dataclasses.fields(AuditEntry))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class JSONFormatter(logging.Formatter):
|
|
25
|
+
"""Formatter that outputs structured JSON logs for Loki compatibility."""
|
|
26
|
+
|
|
27
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
28
|
+
"""Format log record as JSON."""
|
|
29
|
+
log_data: dict[str, Any] = {
|
|
30
|
+
"timestamp": datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
|
|
31
|
+
"level": record.levelname,
|
|
32
|
+
"logger": record.name,
|
|
33
|
+
"message": record.getMessage(),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# Add optional structured fields if present
|
|
37
|
+
log_data |= {field: value for field in OPTIONAL_FIELDS if (value := getattr(record, field, None)) is not None}
|
|
38
|
+
|
|
39
|
+
if record.exc_info:
|
|
40
|
+
log_data["exception"] = self.formatException(record.exc_info)
|
|
41
|
+
return orjson.dumps(log_data, default=str).decode()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def setup_logging(level: str, *, json_format: bool = False) -> None:
|
|
45
|
+
"""Configure logging.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
level: Log level (DEBUG, INFO, WARNING, ERROR).
|
|
49
|
+
json_format: Use structured JSON format for Loki.
|
|
50
|
+
"""
|
|
51
|
+
log_level = getattr(logging, level.upper())
|
|
52
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
53
|
+
handler.setLevel(log_level)
|
|
54
|
+
|
|
55
|
+
if json_format:
|
|
56
|
+
handler.setFormatter(JSONFormatter())
|
|
57
|
+
else:
|
|
58
|
+
handler.setFormatter(
|
|
59
|
+
logging.Formatter(
|
|
60
|
+
fmt="%(asctime)s | %(levelname)-8s | %(name)s - %(message)s",
|
|
61
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
root_logger = logging.getLogger()
|
|
66
|
+
root_logger.setLevel(log_level)
|
|
67
|
+
for existing_handler in root_logger.handlers[:]:
|
|
68
|
+
root_logger.removeHandler(existing_handler)
|
|
69
|
+
root_logger.addHandler(handler)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command()
|
|
73
|
+
def serve(
|
|
74
|
+
port: Annotated[int, typer.Option("--port", "-p", help="Port to listen on")] = 8080,
|
|
75
|
+
host: Annotated[str, typer.Option("--host", "-h", help="Host to bind to")] = "0.0.0.0", # noqa: S104 -- intentional bind to all interfaces
|
|
76
|
+
log_level: Annotated[
|
|
77
|
+
str,
|
|
78
|
+
typer.Option(
|
|
79
|
+
"--log-level",
|
|
80
|
+
"-l",
|
|
81
|
+
envvar="SIE_LOG_LEVEL",
|
|
82
|
+
help="Log level (DEBUG, INFO, WARNING, ERROR).",
|
|
83
|
+
),
|
|
84
|
+
] = "info",
|
|
85
|
+
json_logs: Annotated[
|
|
86
|
+
bool,
|
|
87
|
+
typer.Option(
|
|
88
|
+
"--json-logs",
|
|
89
|
+
envvar="SIE_LOG_JSON",
|
|
90
|
+
help="Enable structured JSON logging for Loki.",
|
|
91
|
+
),
|
|
92
|
+
] = False,
|
|
93
|
+
reload: Annotated[
|
|
94
|
+
bool,
|
|
95
|
+
typer.Option("--reload", "-r", help="Enable auto-reload for development"),
|
|
96
|
+
] = False,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Start the SIE Config Service."""
|
|
99
|
+
setup_logging(log_level, json_format=json_logs)
|
|
100
|
+
|
|
101
|
+
uvicorn.run(
|
|
102
|
+
"sie_config.app_factory:AppFactory.create_app",
|
|
103
|
+
host=host,
|
|
104
|
+
port=port,
|
|
105
|
+
log_level=log_level.lower(),
|
|
106
|
+
reload=reload,
|
|
107
|
+
factory=True,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@app.command()
|
|
112
|
+
def version() -> None:
|
|
113
|
+
"""Show version information."""
|
|
114
|
+
from sie_config import __version__
|
|
115
|
+
|
|
116
|
+
print(f"sie-config {__version__}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def main() -> None:
|
|
120
|
+
"""Entry point for the CLI."""
|
|
121
|
+
app()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
main()
|