servicekit 2.0.2__tar.gz → 3.0.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.
- {servicekit-2.0.2 → servicekit-3.0.0}/PKG-INFO +5 -6
- {servicekit-2.0.2 → servicekit-3.0.0}/README.md +1 -1
- {servicekit-2.0.2 → servicekit-3.0.0}/pyproject.toml +4 -5
- {servicekit-2.0.2 → servicekit-3.0.0}/pyproject.toml.orig +4 -5
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/monitoring.py +4 -17
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/service_builder.py +139 -41
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/utilities.py +9 -7
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/__init__.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/alembic/env.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/alembic/script.py.mako +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/alembic/versions/20251010_0927_4d869b5fb06e_initial_schema.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/__init__.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/app.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/apps/landing/index.html +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/apps/landing/manifest.json +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/auth.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/crud.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/dependencies.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/middleware.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/pagination.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/registration.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/router.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/routers/__init__.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/routers/health.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/routers/job.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/routers/metrics.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/routers/system.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/sse.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/database.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/exceptions.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/gunicorn.conf.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/logging.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/manager.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/models.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/py.typed +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/repository.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/scheduler.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/schemas.py +0 -0
- {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: servicekit
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.0
|
|
4
4
|
Summary: Async SQLAlchemy framework with FastAPI integration - reusable foundation for building data services
|
|
5
5
|
Keywords: fastapi,sqlalchemy,async,database,rest-api,crud,framework
|
|
6
6
|
Author: Morten Hansen
|
|
@@ -15,13 +15,12 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
15
15
|
Classifier: Topic :: Database
|
|
16
16
|
Requires-Dist: aiosqlite>=0.21.0
|
|
17
17
|
Requires-Dist: alembic>=1.17.0
|
|
18
|
-
Requires-Dist: fastapi[standard]>=0.
|
|
18
|
+
Requires-Dist: fastapi[standard]>=0.142.2
|
|
19
19
|
Requires-Dist: gunicorn>=23.0.0
|
|
20
|
-
Requires-Dist: opentelemetry-api>=1.
|
|
20
|
+
Requires-Dist: opentelemetry-api>=1.44.0
|
|
21
21
|
Requires-Dist: opentelemetry-exporter-prometheus>=0.58b0
|
|
22
|
-
Requires-Dist: opentelemetry-instrumentation-fastapi>=0.58b0
|
|
23
22
|
Requires-Dist: opentelemetry-instrumentation-sqlalchemy>=0.58b0
|
|
24
|
-
Requires-Dist: opentelemetry-sdk>=1.
|
|
23
|
+
Requires-Dist: opentelemetry-sdk>=1.44.0
|
|
25
24
|
Requires-Dist: pydantic>=2.12.0
|
|
26
25
|
Requires-Dist: python-ulid>=3.1.0
|
|
27
26
|
Requires-Dist: sqlalchemy[asyncio]>=2.0.43
|
|
@@ -115,7 +114,7 @@ app = (
|
|
|
115
114
|
.with_database(url) # Database configuration
|
|
116
115
|
.with_jobs(max_concurrency=10) # Job scheduler
|
|
117
116
|
.with_auth() # API key authentication
|
|
118
|
-
.with_monitoring() # Prometheus metrics
|
|
117
|
+
.with_monitoring() # Prometheus metrics (on by default)
|
|
119
118
|
.with_app("./webapp") # Static web app
|
|
120
119
|
.include_router(custom_router) # Custom routes
|
|
121
120
|
.build()
|
|
@@ -79,7 +79,7 @@ app = (
|
|
|
79
79
|
.with_database(url) # Database configuration
|
|
80
80
|
.with_jobs(max_concurrency=10) # Job scheduler
|
|
81
81
|
.with_auth() # API key authentication
|
|
82
|
-
.with_monitoring() # Prometheus metrics
|
|
82
|
+
.with_monitoring() # Prometheus metrics (on by default)
|
|
83
83
|
.with_app("./webapp") # Static web app
|
|
84
84
|
.include_router(custom_router) # Custom routes
|
|
85
85
|
.build()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "servicekit"
|
|
3
|
-
version = "
|
|
3
|
+
version = "3.0.0"
|
|
4
4
|
description = "Async SQLAlchemy framework with FastAPI integration - reusable foundation for building data services"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13,<3.14"
|
|
@@ -25,13 +25,12 @@ classifiers = [
|
|
|
25
25
|
dependencies = [
|
|
26
26
|
"aiosqlite>=0.21.0",
|
|
27
27
|
"alembic>=1.17.0",
|
|
28
|
-
"fastapi[standard]>=0.
|
|
28
|
+
"fastapi[standard]>=0.142.2",
|
|
29
29
|
"gunicorn>=23.0.0",
|
|
30
|
-
"opentelemetry-api>=1.
|
|
30
|
+
"opentelemetry-api>=1.44.0",
|
|
31
31
|
"opentelemetry-exporter-prometheus>=0.58b0",
|
|
32
|
-
"opentelemetry-instrumentation-fastapi>=0.58b0",
|
|
33
32
|
"opentelemetry-instrumentation-sqlalchemy>=0.58b0",
|
|
34
|
-
"opentelemetry-sdk>=1.
|
|
33
|
+
"opentelemetry-sdk>=1.44.0",
|
|
35
34
|
"pydantic>=2.12.0",
|
|
36
35
|
"python-ulid>=3.1.0",
|
|
37
36
|
"sqlalchemy[asyncio]>=2.0.43",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "servicekit"
|
|
3
|
-
version = "
|
|
3
|
+
version = "3.0.0"
|
|
4
4
|
description = "Async SQLAlchemy framework with FastAPI integration - reusable foundation for building data services"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{ name = "Morten Hansen", email = "morten@dhis2.org" }]
|
|
@@ -27,13 +27,12 @@ classifiers = [
|
|
|
27
27
|
dependencies = [
|
|
28
28
|
"aiosqlite>=0.21.0",
|
|
29
29
|
"alembic>=1.17.0",
|
|
30
|
-
"fastapi[standard]>=0.
|
|
30
|
+
"fastapi[standard]>=0.142.2",
|
|
31
31
|
"gunicorn>=23.0.0",
|
|
32
|
-
"opentelemetry-api>=1.
|
|
32
|
+
"opentelemetry-api>=1.44.0",
|
|
33
33
|
"opentelemetry-exporter-prometheus>=0.58b0",
|
|
34
|
-
"opentelemetry-instrumentation-fastapi>=0.58b0",
|
|
35
34
|
"opentelemetry-instrumentation-sqlalchemy>=0.58b0",
|
|
36
|
-
"opentelemetry-sdk>=1.
|
|
35
|
+
"opentelemetry-sdk>=1.44.0",
|
|
37
36
|
"pydantic>=2.12.0",
|
|
38
37
|
"python-ulid>=3.1.0",
|
|
39
38
|
"sqlalchemy[asyncio]>=2.0.43",
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
"""OpenTelemetry monitoring setup
|
|
1
|
+
"""OpenTelemetry monitoring setup: Prometheus metrics, SQLAlchemy instrumentation, FastAPI OpenTelemetry."""
|
|
2
2
|
|
|
3
3
|
from fastapi import FastAPI
|
|
4
4
|
from opentelemetry import metrics
|
|
5
5
|
from opentelemetry.exporter.prometheus import PrometheusMetricReader
|
|
6
|
-
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
|
7
6
|
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
|
|
8
7
|
from opentelemetry.sdk.metrics import MeterProvider
|
|
9
8
|
from opentelemetry.sdk.resources import Resource
|
|
@@ -28,7 +27,7 @@ def setup_monitoring(
|
|
|
28
27
|
service_name: str | None = None,
|
|
29
28
|
enable_traces: bool = False,
|
|
30
29
|
) -> PrometheusMetricReader:
|
|
31
|
-
"""Setup
|
|
30
|
+
"""Setup the global Prometheus meter provider and SQLAlchemy instrumentation; FastAPI OpenTelemetry records HTTP."""
|
|
32
31
|
global _meter_provider_initialized, _sqlalchemy_instrumented, _process_collector_registered, _metric_reader
|
|
33
32
|
|
|
34
33
|
# Use app title as service name if not provided
|
|
@@ -54,11 +53,6 @@ def setup_monitoring(
|
|
|
54
53
|
# Already registered
|
|
55
54
|
pass
|
|
56
55
|
|
|
57
|
-
# Auto-instrument FastAPI - check if already instrumented
|
|
58
|
-
instrumentor = FastAPIInstrumentor()
|
|
59
|
-
if not instrumentor.is_instrumented_by_opentelemetry:
|
|
60
|
-
instrumentor.instrument_app(app)
|
|
61
|
-
|
|
62
56
|
# Auto-instrument SQLAlchemy - only once globally
|
|
63
57
|
if not _sqlalchemy_instrumented:
|
|
64
58
|
try:
|
|
@@ -86,19 +80,12 @@ def setup_monitoring(
|
|
|
86
80
|
|
|
87
81
|
|
|
88
82
|
def teardown_monitoring() -> None:
|
|
89
|
-
"""Teardown
|
|
90
|
-
global
|
|
83
|
+
"""Teardown SQLAlchemy instrumentation; the global meter provider cannot be replaced, so its reader stays."""
|
|
84
|
+
global _sqlalchemy_instrumented
|
|
91
85
|
|
|
92
|
-
_meter_provider_initialized = False
|
|
93
86
|
_sqlalchemy_instrumented = False
|
|
94
|
-
_process_collector_registered = False
|
|
95
|
-
_metric_reader = None
|
|
96
87
|
|
|
97
88
|
try:
|
|
98
|
-
# Uninstrument FastAPI
|
|
99
|
-
FastAPIInstrumentor().uninstrument()
|
|
100
|
-
|
|
101
|
-
# Uninstrument SQLAlchemy
|
|
102
89
|
SQLAlchemyInstrumentor().uninstrument()
|
|
103
90
|
|
|
104
91
|
logger.info("monitoring.disabled")
|
|
@@ -5,16 +5,20 @@ from __future__ import annotations
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import os
|
|
7
7
|
import re
|
|
8
|
+
import secrets
|
|
8
9
|
import signal
|
|
9
10
|
import sys
|
|
11
|
+
from collections.abc import MutableMapping, Sequence
|
|
10
12
|
from contextlib import asynccontextmanager
|
|
11
13
|
from dataclasses import dataclass
|
|
12
14
|
from pathlib import Path
|
|
13
15
|
from typing import Any, AsyncContextManager, AsyncGenerator, Awaitable, Callable, Self
|
|
14
16
|
|
|
15
17
|
from fastapi import APIRouter, FastAPI
|
|
18
|
+
from fastapi.telemetry import TelemetryConfig
|
|
16
19
|
from pydantic import BaseModel, ConfigDict, field_validator
|
|
17
20
|
from sqlalchemy import text
|
|
21
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
18
22
|
|
|
19
23
|
from servicekit import Database, SqliteDatabase
|
|
20
24
|
from servicekit.logging import configure_logging, get_logger
|
|
@@ -34,6 +38,10 @@ type LifecycleHook = Callable[[FastAPI], Awaitable[None]]
|
|
|
34
38
|
type DependencyOverride = Callable[..., object]
|
|
35
39
|
type LifespanFactory = Callable[[FastAPI], AsyncContextManager[None]]
|
|
36
40
|
|
|
41
|
+
# Header the readiness probe sends and expects echoed back, so it only accepts its own app
|
|
42
|
+
_READINESS_TOKEN_HEADER = "x-servicekit-readiness-token"
|
|
43
|
+
_DEFAULT_LOCAL_PORT = 8000
|
|
44
|
+
|
|
37
45
|
|
|
38
46
|
@dataclass(frozen=True)
|
|
39
47
|
class _HealthOptions:
|
|
@@ -107,6 +115,8 @@ class _RegistrationOptions:
|
|
|
107
115
|
service_key: str | None
|
|
108
116
|
service_key_env: str
|
|
109
117
|
re_register_grace_period: float
|
|
118
|
+
local_port: int | None = None
|
|
119
|
+
local_port_env: str = "PORT"
|
|
110
120
|
|
|
111
121
|
|
|
112
122
|
class ServiceInfo(BaseModel):
|
|
@@ -159,7 +169,12 @@ class BaseServiceBuilder:
|
|
|
159
169
|
self._system_options: _SystemOptions | None = None
|
|
160
170
|
self._job_options: JobOptions | None = None
|
|
161
171
|
self._auth_options: _AuthOptions | None = None
|
|
162
|
-
self._monitoring_options: _MonitoringOptions | None =
|
|
172
|
+
self._monitoring_options: _MonitoringOptions | None = _MonitoringOptions(
|
|
173
|
+
prefix="/metrics",
|
|
174
|
+
tags=["Observability"],
|
|
175
|
+
service_name=None,
|
|
176
|
+
enable_traces=False,
|
|
177
|
+
)
|
|
163
178
|
self._registration_options: _RegistrationOptions | None = None
|
|
164
179
|
self._app_configs: list[App] = []
|
|
165
180
|
self._custom_routers: list[APIRouter] = []
|
|
@@ -307,13 +322,17 @@ class BaseServiceBuilder:
|
|
|
307
322
|
|
|
308
323
|
def with_monitoring(
|
|
309
324
|
self,
|
|
325
|
+
enabled: bool = True,
|
|
310
326
|
*,
|
|
311
327
|
prefix: str = "/metrics",
|
|
312
328
|
tags: list[str] | None = None,
|
|
313
329
|
service_name: str | None = None,
|
|
314
330
|
enable_traces: bool = False,
|
|
315
331
|
) -> Self:
|
|
316
|
-
"""
|
|
332
|
+
"""Configure OpenTelemetry monitoring (enabled by default); pass enabled=False to turn it off."""
|
|
333
|
+
if not enabled:
|
|
334
|
+
self._monitoring_options = None
|
|
335
|
+
return self
|
|
317
336
|
self._monitoring_options = _MonitoringOptions(
|
|
318
337
|
prefix=prefix,
|
|
319
338
|
tags=list(tags) if tags is not None else ["Observability"],
|
|
@@ -341,8 +360,15 @@ class BaseServiceBuilder:
|
|
|
341
360
|
service_key: str | None = None,
|
|
342
361
|
service_key_env: str = "SERVICEKIT_REGISTRATION_KEY",
|
|
343
362
|
re_register_grace_period: float = 30.0,
|
|
363
|
+
local_port: int | None = None,
|
|
364
|
+
local_port_env: str = "PORT",
|
|
344
365
|
) -> Self:
|
|
345
|
-
"""Enable service registration with orchestrator for service discovery.
|
|
366
|
+
"""Enable service registration with orchestrator for service discovery.
|
|
367
|
+
|
|
368
|
+
``host``/``port`` (or ``host_env``/``port_env``) form the address advertised to the orchestrator.
|
|
369
|
+
``local_port`` (or ``local_port_env``) is the port the app binds locally, probed for readiness
|
|
370
|
+
before registering; set it when the advertised port differs from the bind port.
|
|
371
|
+
"""
|
|
346
372
|
self._registration_options = _RegistrationOptions(
|
|
347
373
|
orchestrator_url=orchestrator_url,
|
|
348
374
|
host=host,
|
|
@@ -360,6 +386,8 @@ class BaseServiceBuilder:
|
|
|
360
386
|
service_key=service_key,
|
|
361
387
|
service_key_env=service_key_env,
|
|
362
388
|
re_register_grace_period=re_register_grace_period,
|
|
389
|
+
local_port=local_port,
|
|
390
|
+
local_port_env=local_port_env,
|
|
363
391
|
)
|
|
364
392
|
return self
|
|
365
393
|
|
|
@@ -408,6 +436,7 @@ class BaseServiceBuilder:
|
|
|
408
436
|
description=self._app_description,
|
|
409
437
|
version=self._version,
|
|
410
438
|
lifespan=lifespan,
|
|
439
|
+
telemetry=self._build_telemetry_config(),
|
|
411
440
|
)
|
|
412
441
|
app.state.database_url = self._database_url
|
|
413
442
|
|
|
@@ -534,8 +563,26 @@ class BaseServiceBuilder:
|
|
|
534
563
|
for dependency, override in self._dependency_overrides.items():
|
|
535
564
|
app.dependency_overrides[dependency] = override
|
|
536
565
|
|
|
566
|
+
# Last, so it wraps any build_middleware_stack patch made above
|
|
567
|
+
if self._registration_options is not None:
|
|
568
|
+
app.state.readiness_token = secrets.token_hex(16)
|
|
569
|
+
app.build_middleware_stack = _wrap_with_readiness_token(app) # type: ignore[method-assign]
|
|
570
|
+
|
|
537
571
|
return app
|
|
538
572
|
|
|
573
|
+
def _build_telemetry_config(self) -> TelemetryConfig:
|
|
574
|
+
"""Build FastAPI OpenTelemetry settings: skip metrics scrapes when enabled, turn everything off when not."""
|
|
575
|
+
if self._monitoring_options is None:
|
|
576
|
+
return {"tracing": False, "metrics": False, "logs": False, "auto_configure": False}
|
|
577
|
+
metrics_prefix = self._monitoring_options.prefix
|
|
578
|
+
|
|
579
|
+
def exclude_metrics_scrape(scope: MutableMapping[str, Any]) -> bool:
|
|
580
|
+
"""Skip telemetry for requests to the metrics endpoint."""
|
|
581
|
+
path = str(scope.get("path", ""))
|
|
582
|
+
return path == metrics_prefix or path.startswith(f"{metrics_prefix}/")
|
|
583
|
+
|
|
584
|
+
return {"exclude": exclude_metrics_scrape}
|
|
585
|
+
|
|
539
586
|
# --------------------------------------------------------------------- Extension points
|
|
540
587
|
|
|
541
588
|
def _validate_module_configuration(self) -> None:
|
|
@@ -882,49 +929,99 @@ class BaseServiceBuilder:
|
|
|
882
929
|
return cls(info=info, **kwargs).build()
|
|
883
930
|
|
|
884
931
|
|
|
885
|
-
|
|
886
|
-
"""
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
932
|
+
class _ReadinessTokenMiddleware:
|
|
933
|
+
"""Echo the readiness token header on responses to requests that present the matching token."""
|
|
934
|
+
|
|
935
|
+
def __init__(self, app: ASGIApp, *, token: str) -> None:
|
|
936
|
+
"""Wrap the ASGI app with the per-instance readiness token."""
|
|
937
|
+
self.app = app
|
|
938
|
+
self.token = token
|
|
939
|
+
|
|
940
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
941
|
+
"""Pass the request through, tagging the response when the probe token matches."""
|
|
942
|
+
if scope["type"] != "http" or not self._has_token(scope):
|
|
943
|
+
await self.app(scope, receive, send)
|
|
944
|
+
return
|
|
945
|
+
|
|
946
|
+
async def send_with_token(message: Message) -> None:
|
|
947
|
+
"""Append the token header to the response start message."""
|
|
948
|
+
if message["type"] == "http.response.start":
|
|
949
|
+
headers = list(message.get("headers", []))
|
|
950
|
+
headers.append((_READINESS_TOKEN_HEADER.encode("latin-1"), self.token.encode("latin-1")))
|
|
951
|
+
message = {**message, "headers": headers}
|
|
952
|
+
await send(message)
|
|
953
|
+
|
|
954
|
+
await self.app(scope, receive, send_with_token)
|
|
955
|
+
|
|
956
|
+
def _has_token(self, scope: Scope) -> bool:
|
|
957
|
+
"""Return whether the request carries this instance's readiness token."""
|
|
958
|
+
expected = (_READINESS_TOKEN_HEADER.encode("latin-1"), self.token.encode("latin-1"))
|
|
959
|
+
return any((name.lower(), value) == expected for name, value in scope.get("headers", []))
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _wrap_with_readiness_token(app: FastAPI) -> Callable[[], ASGIApp]:
|
|
963
|
+
"""Wrap the app's middleware stack builder so readiness token tagging is always the outermost layer."""
|
|
964
|
+
build_middleware_stack = app.build_middleware_stack
|
|
965
|
+
|
|
966
|
+
def build_with_readiness_token() -> ASGIApp:
|
|
967
|
+
"""Build the regular stack, including middleware added after build(), inside the token middleware."""
|
|
968
|
+
return _ReadinessTokenMiddleware(build_middleware_stack(), token=app.state.readiness_token)
|
|
969
|
+
|
|
970
|
+
return build_with_readiness_token
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def _parse_port(value: str | None) -> int | None:
|
|
974
|
+
"""Parse a port from an environment value, returning None when unset or invalid."""
|
|
975
|
+
if not value:
|
|
976
|
+
return None
|
|
977
|
+
try:
|
|
978
|
+
return int(value)
|
|
979
|
+
except ValueError:
|
|
980
|
+
return None
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _local_port_candidates(options: _RegistrationOptions) -> list[int]:
|
|
984
|
+
"""Resolve the local ports to probe for readiness, most specific first."""
|
|
985
|
+
if options.local_port is not None:
|
|
986
|
+
return [options.local_port]
|
|
987
|
+
advertised_port = options.port if options.port is not None else _parse_port(os.getenv(options.port_env))
|
|
988
|
+
candidates = [_parse_port(os.getenv(options.local_port_env)), advertised_port, _DEFAULT_LOCAL_PORT]
|
|
989
|
+
return list(dict.fromkeys(port for port in candidates if port is not None))
|
|
896
990
|
|
|
897
991
|
|
|
898
992
|
async def _wait_until_ready(
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
993
|
+
ports: Sequence[int],
|
|
994
|
+
*,
|
|
995
|
+
token: str,
|
|
996
|
+
health_path: str | None = None,
|
|
997
|
+
poll_interval: float = 0.5,
|
|
998
|
+
timeout: float = 30.0,
|
|
999
|
+
) -> int | None:
|
|
1000
|
+
"""Poll the candidate local ports until this app serves requests, returning the ready port or None."""
|
|
902
1001
|
import httpx
|
|
903
1002
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
else:
|
|
908
|
-
url = f"http://127.0.0.1:{port}/"
|
|
909
|
-
check_kind = "tcp"
|
|
1003
|
+
path = health_path or "/"
|
|
1004
|
+
check_kind = "health" if health_path else "tcp"
|
|
1005
|
+
headers = {_READINESS_TOKEN_HEADER: token}
|
|
910
1006
|
|
|
911
1007
|
deadline = asyncio.get_event_loop().time() + timeout
|
|
912
1008
|
while asyncio.get_event_loop().time() < deadline:
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1009
|
+
for port in ports:
|
|
1010
|
+
try:
|
|
1011
|
+
async with httpx.AsyncClient() as client:
|
|
1012
|
+
response = await client.get(f"http://127.0.0.1:{port}{path}", headers=headers, timeout=2.0)
|
|
1013
|
+
except Exception:
|
|
1014
|
+
continue
|
|
1015
|
+
# Something else listening on a candidate port (e.g. the orchestrator itself) is not us
|
|
1016
|
+
if response.headers.get(_READINESS_TOKEN_HEADER) != token:
|
|
1017
|
+
continue
|
|
1018
|
+
# In tcp mode any response means the server is accepting connections
|
|
1019
|
+
if not health_path or response.status_code == 200:
|
|
1020
|
+
return port
|
|
924
1021
|
await asyncio.sleep(poll_interval)
|
|
925
1022
|
|
|
926
|
-
logger.warning("registration.readiness_timeout",
|
|
927
|
-
return
|
|
1023
|
+
logger.warning("registration.readiness_timeout", ports=list(ports), check=check_kind, timeout=timeout)
|
|
1024
|
+
return None
|
|
928
1025
|
|
|
929
1026
|
|
|
930
1027
|
def _fail_registration(app: FastAPI, reason: str) -> None:
|
|
@@ -992,18 +1089,19 @@ async def _register_after_ready(
|
|
|
992
1089
|
options: _RegistrationOptions, info: BaseModel, app: FastAPI, health_path: str | None
|
|
993
1090
|
) -> None:
|
|
994
1091
|
"""Wait for the app to be ready, then register with the orchestrator."""
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
if
|
|
1092
|
+
ports = _local_port_candidates(options)
|
|
1093
|
+
ready_port = await _wait_until_ready(ports, token=app.state.readiness_token, health_path=health_path)
|
|
1094
|
+
if ready_port is None:
|
|
998
1095
|
if options.fail_on_error:
|
|
999
|
-
_fail_registration(app, f"App on
|
|
1096
|
+
_fail_registration(app, f"App never became ready on local ports {ports}, cannot register")
|
|
1000
1097
|
else:
|
|
1001
1098
|
logger.error(
|
|
1002
1099
|
"registration.aborted",
|
|
1003
|
-
|
|
1100
|
+
ports=ports,
|
|
1004
1101
|
message="App never became ready, skipping registration",
|
|
1005
1102
|
)
|
|
1006
1103
|
return
|
|
1104
|
+
logger.debug("registration.ready", port=ready_port)
|
|
1007
1105
|
|
|
1008
1106
|
# Shield registration from cancellation so that if the POST succeeds,
|
|
1009
1107
|
# app.state.registration_info is always written before the task exits.
|
|
@@ -41,9 +41,10 @@ def run_app(
|
|
|
41
41
|
Args:
|
|
42
42
|
app: FastAPI app instance OR string "module:app" path
|
|
43
43
|
host: Server host (default: "127.0.0.1", env: HOST)
|
|
44
|
-
port: Server port (default: 8000, env: PORT). The resolved value is
|
|
45
|
-
exported as
|
|
46
|
-
|
|
44
|
+
port: Server port (default: 8000, env: PORT). The resolved value is
|
|
45
|
+
exported as PORT so service registration probes the port uvicorn
|
|
46
|
+
binds, and as SERVICEKIT_PORT (unless already set) so it is also
|
|
47
|
+
the advertised port by default.
|
|
47
48
|
workers: Number of worker processes (default: 1, env: WORKERS)
|
|
48
49
|
reload: Enable auto-reload (default: True for string, False for instance)
|
|
49
50
|
log_level: Logging level (default: from LOG_LEVEL env var or "info")
|
|
@@ -71,10 +72,11 @@ def run_app(
|
|
|
71
72
|
reload = False
|
|
72
73
|
|
|
73
74
|
# Make the bind port visible to service registration, which runs inside the
|
|
74
|
-
# app lifespan
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
# proxy) still wins.
|
|
75
|
+
# app lifespan with no knowledge of where uvicorn actually bound. PORT is the
|
|
76
|
+
# local port its readiness probe tries first. SERVICEKIT_PORT is the port
|
|
77
|
+
# advertised to the orchestrator; setdefault means an explicitly set value
|
|
78
|
+
# (e.g. a published host port or a port behind a proxy) still wins.
|
|
79
|
+
os.environ["PORT"] = str(resolved_port)
|
|
78
80
|
os.environ.setdefault("SERVICEKIT_PORT", str(resolved_port))
|
|
79
81
|
|
|
80
82
|
uvicorn.run(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|