From 8ec6dcccc2d21791febbebf65883b9b012d14fa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Tue, 16 Jun 2026 16:20:50 +0200 Subject: [PATCH] feat(actuator): expose live HealthAggregator on app.state (v26.06.108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the live actuator HealthAggregator on app.state.pyfly_health_aggregator so consumers can register extra health indicators after create_app — e.g. a readiness-only probe for an external dependency — without introspecting route closures. It is the SAME aggregator the live health routes use whether actuator runs on the main app (shared management mode) or the separate management port, so indicators added through it reflect on /actuator/health in either mode. Unblocks single-container apps that adopt the two-port management model and need to register their own readiness probe (cdm-mexico Fabric probe). --- CHANGELOG.md | 14 ++++++ pyproject.toml | 2 +- src/pyfly/__init__.py | 2 +- src/pyfly/web/adapters/starlette/app.py | 9 ++++ tests/web/test_management_separation.py | 65 +++++++++++++++++++++++++ uv.lock | 2 +- 6 files changed, 91 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2bcffd..fc4962b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.06.108 (2026-06-16) + +### Added + +- **The live actuator ``HealthAggregator`` is exposed on + ``app.state.pyfly_health_aggregator``.** Consumers can now register extra + health indicators after ``create_app`` (e.g. a readiness-only probe for an + external dependency) without introspecting route closures. It is the *same* + aggregator the live health routes use — whether actuator runs on the main app + (shared management mode) or on the separate management port — so indicators + added through it are reflected on ``/actuator/health`` in either mode. + +--- + ## v26.06.107 (2026-06-16) ### Added diff --git a/pyproject.toml b/pyproject.toml index b0a0749..1377ca9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "pyfly" # CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4); # git tag, GitHub release and human-readable display use leading-zero form # (v26.05.04) to match the Java/.NET/Go siblings. -version = "26.6.107" +version = "26.6.108" description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more." readme = "README.md" license = "Apache-2.0" diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py index e63ba55..506ad61 100644 --- a/src/pyfly/__init__.py +++ b/src/pyfly/__init__.py @@ -13,4 +13,4 @@ # limitations under the License. """PyFly — Enterprise Python Framework.""" -__version__ = "26.06.107" +__version__ = "26.06.108" diff --git a/src/pyfly/web/adapters/starlette/app.py b/src/pyfly/web/adapters/starlette/app.py index 5b285b4..c6799a6 100644 --- a/src/pyfly/web/adapters/starlette/app.py +++ b/src/pyfly/web/adapters/starlette/app.py @@ -460,6 +460,15 @@ async def _lifespan_with_dynamic_wiring(app_: Starlette) -> AsyncIterator[None]: app.state.pyfly_route_metadata = route_metadata app.state.pyfly_docs_enabled = docs_enabled + # Expose the live actuator HealthAggregator so consumers can register extra + # health indicators after create_app (e.g. a readiness-only probe for an + # external dependency). This is the SAME aggregator the live health routes + # use — whether actuator runs on the main app (shared mode) or on the + # separate management app — so indicators added here are reflected on + # /actuator/health regardless of management mode. + if agg is not None: + app.state.pyfly_health_aggregator = agg + # Expose the post-start rescan for callers that manage their own lifespan. app.state.pyfly_install_dynamic_wiring = lambda: _install_dynamic_wiring(app) if actuator_active: diff --git a/tests/web/test_management_separation.py b/tests/web/test_management_separation.py index b864293..9ebcd94 100644 --- a/tests/web/test_management_separation.py +++ b/tests/web/test_management_separation.py @@ -128,6 +128,71 @@ async def test_separate_mode_without_lifespan_degrades_to_shared() -> None: assert any(p.startswith("/admin") for p in paths) +@pytest.mark.asyncio +async def test_health_aggregator_exposed_and_live_in_shared_mode() -> None: + # app.state.pyfly_health_aggregator must be the SAME aggregator backing the + # live health routes: an indicator added to it post-create_app shows up on + # /actuator/health (the mechanism cdm-mexico uses for its Fabric readiness probe). + from starlette.testclient import TestClient + + from pyfly.actuator.health import HealthAggregator, HealthStatus, ProbeGroup + + class _DownDep: + async def health(self) -> HealthStatus: + return HealthStatus(status="DOWN", details={"reason": "offline"}) + + ctx = ApplicationContext( + Config({"pyfly": {"management": {"endpoints": {"web": {"exposure": {"include": "health"}}}}}}) + ) + + @contextlib.asynccontextmanager + async def _lifespan(app_: Any): + await ctx.start() + app_.state.pyfly_install_dynamic_wiring() + yield + await ctx.stop() + + app = create_app(context=ctx, docs_enabled=False, lifespan=_lifespan) + agg = getattr(app.state, "pyfly_health_aggregator", None) + assert isinstance(agg, HealthAggregator) + + with TestClient(app) as client: + assert client.get("/actuator/health/readiness").status_code == 200 + # Register a readiness-only DOWN indicator on the exposed aggregator. + agg.add_indicator("dep", _DownDep(), groups={ProbeGroup.READINESS}) + readiness = client.get("/actuator/health/readiness") + assert readiness.status_code == 503 + assert readiness.json()["components"]["dep"]["status"] == "DOWN" + # Readiness-only: liveness stays UP (the probe-group semantics hold). + assert client.get("/actuator/health/liveness").status_code == 200 + + +@pytest.mark.asyncio +async def test_health_aggregator_exposed_in_separate_mode() -> None: + # Even when actuator runs on a separate management port, the aggregator is + # exposed on the main app's state (it is the same instance the management app + # serves), so consumers holding the main app can still register indicators. + ctx = ApplicationContext( + Config( + { + "pyfly": { + "server": {"port": 8080}, + "management": {"server": {"port": 9096}, "endpoints": {"web": {"exposure": {"include": "*"}}}}, + } + } + ) + ) + await ctx.start() + try: + from pyfly.actuator.health import HealthAggregator + + # No lifespan entry → the management listener never binds a real port. + app = create_app(context=ctx, docs_enabled=False, lifespan=_noop_lifespan) + assert isinstance(getattr(app.state, "pyfly_health_aggregator", None), HealthAggregator) + finally: + await ctx.stop() + + @pytest.mark.asyncio async def test_equal_port_stays_shared() -> None: paths = await _main_paths( diff --git a/uv.lock b/uv.lock index 6891033..6a8a927 100644 --- a/uv.lock +++ b/uv.lock @@ -2160,7 +2160,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.6.107" +version = "26.6.108" source = { editable = "." } dependencies = [ { name = "pydantic" },