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" },