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 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()