sie-config 0.8.0__tar.gz
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-0.8.0/.gitignore +273 -0
- sie_config-0.8.0/Dockerfile +82 -0
- sie_config-0.8.0/PKG-INFO +22 -0
- sie_config-0.8.0/pyproject.toml +41 -0
- sie_config-0.8.0/src/sie_config/__init__.py +1 -0
- sie_config-0.8.0/src/sie_config/app_factory.py +249 -0
- sie_config-0.8.0/src/sie_config/cli.py +125 -0
- sie_config-0.8.0/src/sie_config/config_api.py +1645 -0
- sie_config-0.8.0/src/sie_config/config_store.py +218 -0
- sie_config-0.8.0/src/sie_config/health.py +27 -0
- sie_config-0.8.0/src/sie_config/managed_metrics.py +431 -0
- sie_config-0.8.0/src/sie_config/metrics.py +72 -0
- sie_config-0.8.0/src/sie_config/model_registry.py +1796 -0
- sie_config-0.8.0/src/sie_config/nats_publisher.py +382 -0
- sie_config-0.8.0/src/sie_config/types.py +58 -0
- sie_config-0.8.0/src/sie_config/version.py +12 -0
- sie_config-0.8.0/tests/__init__.py +0 -0
- sie_config-0.8.0/tests/conftest.py +76 -0
- sie_config-0.8.0/tests/test_canonical_profile_conformance.py +46 -0
- sie_config-0.8.0/tests/test_catalog_withdrawal.py +205 -0
- sie_config-0.8.0/tests/test_config_api.py +2392 -0
- sie_config-0.8.0/tests/test_config_store.py +136 -0
- sie_config-0.8.0/tests/test_dockerfile_contract.py +11 -0
- sie_config-0.8.0/tests/test_hash_consistency.py +380 -0
- sie_config-0.8.0/tests/test_managed_metrics.py +461 -0
- sie_config-0.8.0/tests/test_metrics.py +239 -0
- sie_config-0.8.0/tests/test_model_registry.py +1057 -0
- sie_config-0.8.0/tests/test_model_registry_mutation.py +730 -0
- sie_config-0.8.0/tests/test_nats_publisher.py +340 -0
- sie_config-0.8.0/tests/test_telemetry_benchmark.py +139 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
.codex/artifacts/worktree-task/
|
|
3
|
+
|
|
4
|
+
__pycache__/
|
|
5
|
+
*.py[codz]
|
|
6
|
+
*$py.class
|
|
7
|
+
|
|
8
|
+
# C extensions
|
|
9
|
+
*.so
|
|
10
|
+
|
|
11
|
+
# Distribution / packaging
|
|
12
|
+
.Python
|
|
13
|
+
build/
|
|
14
|
+
develop-eggs/
|
|
15
|
+
dist/
|
|
16
|
+
downloads/
|
|
17
|
+
eggs/
|
|
18
|
+
.eggs/
|
|
19
|
+
lib/
|
|
20
|
+
lib64/
|
|
21
|
+
# JS/TS projects under tools/ legitimately use a `lib/` directory.
|
|
22
|
+
!tools/*/lib/
|
|
23
|
+
!tools/*/lib/**
|
|
24
|
+
parts/
|
|
25
|
+
sdist/
|
|
26
|
+
var/
|
|
27
|
+
wheels/
|
|
28
|
+
share/python-wheels/
|
|
29
|
+
*.egg-info/
|
|
30
|
+
.installed.cfg
|
|
31
|
+
*.egg
|
|
32
|
+
MANIFEST
|
|
33
|
+
|
|
34
|
+
# PyInstaller
|
|
35
|
+
# Usually these files are written by a python script from a template
|
|
36
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
37
|
+
*.manifest
|
|
38
|
+
*.spec
|
|
39
|
+
|
|
40
|
+
# Installer logs
|
|
41
|
+
pip-log.txt
|
|
42
|
+
pip-delete-this-directory.txt
|
|
43
|
+
|
|
44
|
+
# Unit test / coverage reports
|
|
45
|
+
htmlcov/
|
|
46
|
+
.tox/
|
|
47
|
+
.nox/
|
|
48
|
+
.coverage
|
|
49
|
+
.coverage.*
|
|
50
|
+
.cache
|
|
51
|
+
nosetests.xml
|
|
52
|
+
coverage.xml
|
|
53
|
+
*.cover
|
|
54
|
+
*.py.cover
|
|
55
|
+
.hypothesis/
|
|
56
|
+
.pytest_cache/
|
|
57
|
+
cover/
|
|
58
|
+
|
|
59
|
+
# Translations
|
|
60
|
+
*.mo
|
|
61
|
+
*.pot
|
|
62
|
+
|
|
63
|
+
# Django stuff:
|
|
64
|
+
*.log
|
|
65
|
+
local_settings.py
|
|
66
|
+
db.sqlite3
|
|
67
|
+
db.sqlite3-journal
|
|
68
|
+
|
|
69
|
+
# Flask stuff:
|
|
70
|
+
instance/
|
|
71
|
+
.webassets-cache
|
|
72
|
+
|
|
73
|
+
# Scrapy stuff:
|
|
74
|
+
.scrapy
|
|
75
|
+
|
|
76
|
+
# Sphinx documentation
|
|
77
|
+
docs/_build/
|
|
78
|
+
|
|
79
|
+
# PyBuilder
|
|
80
|
+
.pybuilder/
|
|
81
|
+
target/
|
|
82
|
+
|
|
83
|
+
# Rust
|
|
84
|
+
*.rs.bk
|
|
85
|
+
|
|
86
|
+
# Jupyter Notebook
|
|
87
|
+
.ipynb_checkpoints
|
|
88
|
+
|
|
89
|
+
# IPython
|
|
90
|
+
profile_default/
|
|
91
|
+
ipython_config.py
|
|
92
|
+
|
|
93
|
+
# pyenv
|
|
94
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
95
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
96
|
+
# .python-version
|
|
97
|
+
|
|
98
|
+
# pipenv
|
|
99
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
100
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
101
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
102
|
+
# install all needed dependencies.
|
|
103
|
+
#Pipfile.lock
|
|
104
|
+
|
|
105
|
+
# UV
|
|
106
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
107
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
108
|
+
# commonly ignored for libraries.
|
|
109
|
+
#uv.lock
|
|
110
|
+
|
|
111
|
+
# poetry
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
113
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
114
|
+
# commonly ignored for libraries.
|
|
115
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
116
|
+
#poetry.lock
|
|
117
|
+
#poetry.toml
|
|
118
|
+
|
|
119
|
+
# pdm
|
|
120
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
121
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
122
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
123
|
+
#pdm.lock
|
|
124
|
+
#pdm.toml
|
|
125
|
+
.pdm-python
|
|
126
|
+
.pdm-build/
|
|
127
|
+
|
|
128
|
+
# pixi
|
|
129
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
130
|
+
#pixi.lock
|
|
131
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
132
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
133
|
+
.pixi
|
|
134
|
+
|
|
135
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
136
|
+
__pypackages__/
|
|
137
|
+
|
|
138
|
+
# Celery stuff
|
|
139
|
+
celerybeat-schedule
|
|
140
|
+
celerybeat.pid
|
|
141
|
+
|
|
142
|
+
# SageMath parsed files
|
|
143
|
+
*.sage.py
|
|
144
|
+
|
|
145
|
+
# Environments
|
|
146
|
+
.env
|
|
147
|
+
# Local service credential bundles must never be committed.
|
|
148
|
+
*-secrets.env
|
|
149
|
+
.env.cloud
|
|
150
|
+
.venv
|
|
151
|
+
env/
|
|
152
|
+
venv/
|
|
153
|
+
ENV/
|
|
154
|
+
env.bak/
|
|
155
|
+
venv.bak/
|
|
156
|
+
|
|
157
|
+
# Spyder project settings
|
|
158
|
+
.spyderproject
|
|
159
|
+
.spyproject
|
|
160
|
+
|
|
161
|
+
# Rope project settings
|
|
162
|
+
.ropeproject
|
|
163
|
+
|
|
164
|
+
# mkdocs documentation
|
|
165
|
+
/site
|
|
166
|
+
|
|
167
|
+
# Pyre type checker
|
|
168
|
+
.pyre/
|
|
169
|
+
|
|
170
|
+
# pytype static type analyzer
|
|
171
|
+
.pytype/
|
|
172
|
+
|
|
173
|
+
# Cython debug symbols
|
|
174
|
+
cython_debug/
|
|
175
|
+
|
|
176
|
+
# PyCharm
|
|
177
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
178
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
179
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
180
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
181
|
+
#.idea/
|
|
182
|
+
|
|
183
|
+
# Abstra
|
|
184
|
+
# Abstra is an AI-powered process automation framework.
|
|
185
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
186
|
+
# Learn more at https://abstra.io/docs
|
|
187
|
+
.abstra/
|
|
188
|
+
|
|
189
|
+
# Visual Studio Code
|
|
190
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
191
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
192
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
193
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
194
|
+
# .vscode/
|
|
195
|
+
|
|
196
|
+
# Ruff stuff:
|
|
197
|
+
.ruff_cache/
|
|
198
|
+
|
|
199
|
+
# PyPI configuration file
|
|
200
|
+
.pypirc
|
|
201
|
+
|
|
202
|
+
# Cursor
|
|
203
|
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
|
204
|
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
|
205
|
+
# refer to https://docs.cursor.com/context/ignore-files
|
|
206
|
+
.cursorignore
|
|
207
|
+
.cursorindexingignore
|
|
208
|
+
|
|
209
|
+
# vcscode
|
|
210
|
+
.vscode
|
|
211
|
+
|
|
212
|
+
# Marimo
|
|
213
|
+
marimo/_static/
|
|
214
|
+
marimo/_lsp/
|
|
215
|
+
__marimo__/
|
|
216
|
+
|
|
217
|
+
# SIE specific
|
|
218
|
+
# Model weights cache
|
|
219
|
+
.cache/
|
|
220
|
+
*.safetensors
|
|
221
|
+
*.bin
|
|
222
|
+
|
|
223
|
+
# Secrets (never commit)
|
|
224
|
+
*.pem
|
|
225
|
+
*.key
|
|
226
|
+
credentials.json
|
|
227
|
+
*-key.json
|
|
228
|
+
|
|
229
|
+
# Terraform
|
|
230
|
+
# Local .terraform directories (cached providers/modules)
|
|
231
|
+
**/.terraform/*
|
|
232
|
+
.terraform.lock*
|
|
233
|
+
# State files (contain sensitive data)
|
|
234
|
+
*.tfstate
|
|
235
|
+
*.tfstate.*
|
|
236
|
+
# Crash log files
|
|
237
|
+
crash.log
|
|
238
|
+
crash.*.log
|
|
239
|
+
# Override files (local developer overrides)
|
|
240
|
+
override.tf
|
|
241
|
+
override.tf.json
|
|
242
|
+
*_override.tf
|
|
243
|
+
*_override.tf.json
|
|
244
|
+
# tfvars files may contain secrets
|
|
245
|
+
*.tfvars
|
|
246
|
+
*.tfvars.json
|
|
247
|
+
|
|
248
|
+
# Node.js
|
|
249
|
+
node_modules/
|
|
250
|
+
|
|
251
|
+
# OS
|
|
252
|
+
.DS_Store
|
|
253
|
+
Thumbs.db
|
|
254
|
+
|
|
255
|
+
# VIM
|
|
256
|
+
*.swp
|
|
257
|
+
|
|
258
|
+
# kilocode
|
|
259
|
+
.kilo/
|
|
260
|
+
.kilocode/
|
|
261
|
+
|
|
262
|
+
# Worktree metadata
|
|
263
|
+
.base-branch
|
|
264
|
+
|
|
265
|
+
# Temporary files
|
|
266
|
+
tmp/
|
|
267
|
+
.tmp/
|
|
268
|
+
.local/
|
|
269
|
+
|
|
270
|
+
b6_results.json
|
|
271
|
+
# Personal mise overrides (per-developer toolchain tweaks, e.g. rustup
|
|
272
|
+
# component naming differences across rustup versions).
|
|
273
|
+
mise.local.toml
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1.6
|
|
2
|
+
# SIE Config Service - Config control plane for SIE clusters
|
|
3
|
+
# Lightweight image (~150MB) - no GPU dependencies
|
|
4
|
+
#
|
|
5
|
+
# Build:
|
|
6
|
+
# docker build -t sie-config:latest -f packages/sie_config/Dockerfile .
|
|
7
|
+
#
|
|
8
|
+
# Run:
|
|
9
|
+
# docker run -p 8080:8080 sie-config:latest
|
|
10
|
+
|
|
11
|
+
# =============================================================================
|
|
12
|
+
# Dependency builder: install service dependencies
|
|
13
|
+
# =============================================================================
|
|
14
|
+
FROM python:3.12-slim AS builder
|
|
15
|
+
|
|
16
|
+
WORKDIR /app
|
|
17
|
+
|
|
18
|
+
# Copy sie-sdk first (dependency of sie-config)
|
|
19
|
+
COPY packages/sie_sdk /tmp/sie_sdk
|
|
20
|
+
|
|
21
|
+
# Copy sie-config files
|
|
22
|
+
COPY packages/sie_config/pyproject.toml ./
|
|
23
|
+
COPY packages/sie_config/src src/
|
|
24
|
+
|
|
25
|
+
# Create venv and install everything with pip
|
|
26
|
+
RUN --mount=type=cache,target=/root/.cache/pip \
|
|
27
|
+
python -m venv .venv \
|
|
28
|
+
&& .venv/bin/pip install --upgrade pip \
|
|
29
|
+
&& .venv/bin/pip install \
|
|
30
|
+
-e /tmp/sie_sdk \
|
|
31
|
+
-e "."
|
|
32
|
+
|
|
33
|
+
# Clean up to reduce image size
|
|
34
|
+
RUN find .venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true \
|
|
35
|
+
&& find .venv -type f -name "*.pyc" -delete 2>/dev/null || true \
|
|
36
|
+
&& find .venv -type d -name "tests" -exec rm -rf {} + 2>/dev/null || true \
|
|
37
|
+
&& find .venv -type d -name "test" -exec rm -rf {} + 2>/dev/null || true
|
|
38
|
+
|
|
39
|
+
# =============================================================================
|
|
40
|
+
# Runtime image: minimal config service
|
|
41
|
+
# =============================================================================
|
|
42
|
+
FROM python:3.12-slim AS runtime
|
|
43
|
+
|
|
44
|
+
# Create non-root user
|
|
45
|
+
RUN groupadd --gid 1000 sie \
|
|
46
|
+
&& useradd --uid 1000 --gid 1000 --create-home --shell /bin/bash sie
|
|
47
|
+
|
|
48
|
+
WORKDIR /app
|
|
49
|
+
|
|
50
|
+
# Copy virtual environment from builder
|
|
51
|
+
COPY --from=builder --chown=sie:sie /app/.venv /app/.venv
|
|
52
|
+
|
|
53
|
+
# Copy source code (pip -e creates editable install with .pth pointing to src)
|
|
54
|
+
COPY --from=builder --chown=sie:sie /app/src /app/src
|
|
55
|
+
|
|
56
|
+
# Copy sie-sdk source (installed as editable in /tmp)
|
|
57
|
+
COPY --from=builder --chown=sie:sie /tmp/sie_sdk/src /tmp/sie_sdk/src
|
|
58
|
+
|
|
59
|
+
# Copy model and bundle configs (required for ModelRegistry)
|
|
60
|
+
# Config service needs these to know which models exist and their bundle mappings
|
|
61
|
+
COPY --chown=sie:sie packages/sie_server/bundles /app/bundles
|
|
62
|
+
COPY --chown=sie:sie packages/sie_server/models /app/models
|
|
63
|
+
|
|
64
|
+
# Set up PATH and model config paths
|
|
65
|
+
ENV PATH="/app/.venv/bin:$PATH" \
|
|
66
|
+
PYTHONUNBUFFERED=1 \
|
|
67
|
+
PYTHONDONTWRITEBYTECODE=1 \
|
|
68
|
+
SIE_BUNDLES_DIR=/app/bundles \
|
|
69
|
+
SIE_MODELS_DIR=/app/models
|
|
70
|
+
|
|
71
|
+
USER sie
|
|
72
|
+
|
|
73
|
+
# Default port
|
|
74
|
+
EXPOSE 8080
|
|
75
|
+
|
|
76
|
+
# Health check
|
|
77
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
|
78
|
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/healthz')" || exit 1
|
|
79
|
+
|
|
80
|
+
# Default command
|
|
81
|
+
ENTRYPOINT ["sie-config", "serve"]
|
|
82
|
+
CMD ["--port", "8080"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sie-config
|
|
3
|
+
Version: 0.8.0
|
|
4
|
+
Summary: SIE Config Service - Config control plane for SIE clusters
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Requires-Python: <3.13,>=3.12
|
|
7
|
+
Requires-Dist: fastapi<1,>=0.115
|
|
8
|
+
Requires-Dist: nats-py<3,>=2.9
|
|
9
|
+
Requires-Dist: opentelemetry-api<2,>=1.28
|
|
10
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc<2,>=1.28
|
|
11
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http<2,>=1.28
|
|
12
|
+
Requires-Dist: opentelemetry-sdk<2,>=1.28
|
|
13
|
+
Requires-Dist: orjson<4,>=3.10
|
|
14
|
+
Requires-Dist: pyyaml<7,>=6
|
|
15
|
+
Requires-Dist: requests<3,>=2.32
|
|
16
|
+
Requires-Dist: rich<14,>=13
|
|
17
|
+
Requires-Dist: sie-sdk[storage]>=0.1
|
|
18
|
+
Requires-Dist: typer<1,>=0.14
|
|
19
|
+
Requires-Dist: uvicorn[standard]<1,>=0.32
|
|
20
|
+
Requires-Dist: uvloop>=0.22
|
|
21
|
+
Provides-Extra: gcs
|
|
22
|
+
Requires-Dist: google-cloud-storage<3,>=2.18; extra == 'gcs'
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sie-config"
|
|
3
|
+
version = "0.8.0"
|
|
4
|
+
description = "SIE Config Service - Config control plane for SIE clusters"
|
|
5
|
+
requires-python = ">=3.12,<3.13"
|
|
6
|
+
license = { text = "Apache-2.0" }
|
|
7
|
+
dependencies = [
|
|
8
|
+
"fastapi>=0.115,<1",
|
|
9
|
+
"uvicorn[standard]>=0.32,<1",
|
|
10
|
+
"uvloop>=0.22",
|
|
11
|
+
"nats-py>=2.9,<3",
|
|
12
|
+
"opentelemetry-api>=1.28,<2",
|
|
13
|
+
"opentelemetry-exporter-otlp-proto-grpc>=1.28,<2",
|
|
14
|
+
"opentelemetry-exporter-otlp-proto-http>=1.28,<2",
|
|
15
|
+
"opentelemetry-sdk>=1.28,<2",
|
|
16
|
+
"orjson>=3.10,<4",
|
|
17
|
+
"pyyaml>=6,<7",
|
|
18
|
+
"requests>=2.32,<3",
|
|
19
|
+
"sie-sdk[storage]>=0.1",
|
|
20
|
+
"typer>=0.14,<1",
|
|
21
|
+
"rich>=13,<14",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
gcs = ["google-cloud-storage>=2.18,<3"]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
sie-config = "sie_config.cli:app"
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["hatchling"]
|
|
32
|
+
build-backend = "hatchling.build"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/sie_config"]
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
dev = [
|
|
39
|
+
"pytest>=9.0.3",
|
|
40
|
+
"pytest-asyncio>=1.0",
|
|
41
|
+
]
|
|
@@ -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()
|