From 38595e472ce0d09bc3973e6d651a84cb173e8721 Mon Sep 17 00:00:00 2001 From: ancongui Date: Fri, 12 Jun 2026 11:07:21 +0200 Subject: [PATCH] feat(actuator): public install_health_indicators container scan; release v26.06.98 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HealthIndicator container scan existed only as private closures in the FastAPI and Starlette create_app factories, each reaching into container._registrations. Promote it to a public, documented helper — pyfly.actuator.install_health_indicators — built on the container's public registered_types()/get_registration() surface, with a groups= parameter for probe-group membership, and export it together with build_actuator_routes from pyfly.actuator. All four adapter call sites (actuator + admin, both adapters) now delegate to it, so standalone processes (e.g. EDA workers serving /actuator/health/* from their own Starlette app) can wire indicators without copying framework internals. HealthAggregator gains has_indicator() and defensively copies the groups set on add_indicator(). --- CHANGELOG.md | 21 ++++ pyproject.toml | 2 +- src/pyfly/actuator/__init__.py | 3 + src/pyfly/actuator/health.py | 6 +- src/pyfly/actuator/wiring.py | 40 ++++++- src/pyfly/web/adapters/fastapi/app.py | 23 ++-- src/pyfly/web/adapters/starlette/app.py | 30 ++---- .../actuator/test_health_indicators_wiring.py | 101 +++++++++++++++++- uv.lock | 2 +- 9 files changed, 182 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e53013a..39d3012f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.06.98 (2026-06-12) + +### Added + +- **Public health-indicator container scan: `pyfly.actuator.install_health_indicators`.** + The scan that discovers `HealthIndicator` beans in a started `ApplicationContext` and + registers them on a `HealthAggregator` existed only as private closures inside the + FastAPI and Starlette `create_app` factories, each reaching into + `container._registrations`. It is now a public, documented helper in + `pyfly.actuator.wiring` (exported from `pyfly.actuator`, alongside + `build_actuator_routes`) built on the container's public + `registered_types()` / `get_registration()` surface, with an optional `groups=` + parameter to assign probe-group membership to every scanned indicator. Both web + adapters (actuator + admin paths) now delegate to it, and out-of-web-stack + processes — e.g. EDA workers that serve `/actuator/health/*` from a standalone + Starlette app — can wire indicators without copying framework internals. + `HealthAggregator` also gains a public `has_indicator(name)` accessor. + Tests in `tests/actuator/test_health_indicators_wiring.py`. + +--- + ## v26.06.97 (2026-06-11) ### Fixed diff --git a/pyproject.toml b/pyproject.toml index f51d1420..3c7465e0 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.97" +version = "26.6.98" 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/actuator/__init__.py b/src/pyfly/actuator/__init__.py index 32462f01..387bf3ff 100644 --- a/src/pyfly/actuator/__init__.py +++ b/src/pyfly/actuator/__init__.py @@ -16,6 +16,7 @@ from pyfly.actuator.health import HealthAggregator, HealthIndicator, HealthResult, HealthStatus, ProbeGroup from pyfly.actuator.ports import ActuatorEndpoint from pyfly.actuator.registry import ActuatorRegistry +from pyfly.actuator.wiring import build_actuator_routes, install_health_indicators __all__ = [ "ActuatorEndpoint", @@ -25,4 +26,6 @@ "HealthResult", "HealthStatus", "ProbeGroup", + "build_actuator_routes", + "install_health_indicators", ] diff --git a/src/pyfly/actuator/health.py b/src/pyfly/actuator/health.py index 9a4c2aac..4c7f0bc8 100644 --- a/src/pyfly/actuator/health.py +++ b/src/pyfly/actuator/health.py @@ -104,7 +104,11 @@ def add_indicator( ) -> None: """Register a named health indicator with optional probe group membership.""" self._indicators[name] = indicator - self._groups[name] = groups if groups else set() + self._groups[name] = set(groups) if groups else set() + + def has_indicator(self, name: str) -> bool: + """Whether an indicator is registered under *name*.""" + return name in self._indicators async def check(self) -> HealthResult: """Run all indicators and return an aggregated health result. diff --git a/src/pyfly/actuator/wiring.py b/src/pyfly/actuator/wiring.py index 4fb8a6c1..fbfe7c9d 100644 --- a/src/pyfly/actuator/wiring.py +++ b/src/pyfly/actuator/wiring.py @@ -22,7 +22,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from pyfly.actuator.health import HealthAggregator + from pyfly.actuator.health import HealthAggregator, ProbeGroup from pyfly.actuator.http_exchanges import HttpExchangeRecorder from pyfly.context.application_context import ApplicationContext @@ -45,6 +45,44 @@ def resolve_actuator_active(context: ApplicationContext | None, actuator_enabled ).lower() in ("true", "1", "yes") +def install_health_indicators( + context: ApplicationContext | None, + aggregator: HealthAggregator, + *, + groups: set[ProbeGroup] | None = None, +) -> None: + """Register every instantiated ``HealthIndicator`` bean from *context* on *aggregator*. + + The scan walks the container's registrations and picks up every bean whose + singleton instance implements :class:`pyfly.actuator.health.HealthIndicator`, + named after the bean name (falling back to the class name). Beans that have + never been instantiated (e.g. ``@lazy`` singletons that were never resolved) + are skipped, so callers should run the scan after the application context has + started. Indicators already registered on *aggregator* keep their existing + registration — re-running the scan is idempotent. + + *groups* assigns probe-group membership to every indicator the scan adds; + ``None`` keeps the default (the indicator participates in both liveness and + readiness). + + The container keeps one registration per bean type, so two indicator beans + of the same concrete class are discovered only once (the last registered). + """ + if context is None: + return + from pyfly.actuator.health import HealthIndicator + + container = context.container + for cls in container.registered_types(): + reg = container.get_registration(cls) + if reg is None or reg.instance is None or not isinstance(reg.instance, HealthIndicator): + continue + name = reg.name or cls.__name__ + if aggregator.has_indicator(name): + continue + aggregator.add_indicator(name, reg.instance, groups=groups) + + def _health_show(config: Any, key: str) -> bool: """Map ``pyfly.management.endpoint.health.{key}`` (never|when-authorized|always) to a boolean. Defaults to ``when-authorized`` (shown).""" diff --git a/src/pyfly/web/adapters/fastapi/app.py b/src/pyfly/web/adapters/fastapi/app.py index 0d228a96..6f5069ba 100644 --- a/src/pyfly/web/adapters/fastapi/app.py +++ b/src/pyfly/web/adapters/fastapi/app.py @@ -296,8 +296,8 @@ def _add(new_routes: object) -> None: # Mount actuator endpoints when active (actuator_active resolved above). agg = None if actuator_active: - from pyfly.actuator.health import HealthAggregator, HealthIndicator - from pyfly.actuator.wiring import build_actuator_routes + from pyfly.actuator.health import HealthAggregator + from pyfly.actuator.wiring import build_actuator_routes, install_health_indicators agg = HealthAggregator() @@ -306,16 +306,7 @@ def _add(new_routes: object) -> None: # ``create_app`` time, so we expose the scanner on ``app.state`` # and let the downstream lifespan rerun it after startup. def _install_indicators() -> None: - if context is None: - return - seen = set(agg._indicators.keys()) # noqa: SLF001 - for cls, reg in context.container._registrations.items(): - if reg.instance is not None and isinstance(reg.instance, HealthIndicator): - indicator_name = reg.name or cls.__name__ - if indicator_name in seen: - continue - agg.add_indicator(indicator_name, reg.instance) - seen.add(indicator_name) + install_health_indicators(context, agg) _install_indicators() app.state.pyfly_install_health_indicators = _install_indicators @@ -363,13 +354,11 @@ def _install_indicators() -> None: # Reuse health aggregator from actuator, or create one for admin health_agg = agg if health_agg is None: - from pyfly.actuator.health import HealthAggregator, HealthIndicator + from pyfly.actuator.health import HealthAggregator + from pyfly.actuator.wiring import install_health_indicators health_agg = HealthAggregator() - for cls, reg in context.container._registrations.items(): - if reg.instance is not None and isinstance(reg.instance, HealthIndicator): - indicator_name = reg.name or cls.__name__ - health_agg.add_indicator(indicator_name, reg.instance) + install_health_indicators(context, health_agg) admin_builder = AdminRouteBuilder( properties=admin_props, diff --git a/src/pyfly/web/adapters/starlette/app.py b/src/pyfly/web/adapters/starlette/app.py index 892897f9..7f5fa877 100644 --- a/src/pyfly/web/adapters/starlette/app.py +++ b/src/pyfly/web/adapters/starlette/app.py @@ -290,8 +290,8 @@ def _collect_context_routes() -> list[Route]: # Mount actuator endpoints when active (actuator_active resolved above). agg = None if actuator_active: - from pyfly.actuator.health import HealthAggregator, HealthIndicator - from pyfly.actuator.wiring import build_actuator_routes + from pyfly.actuator.health import HealthAggregator + from pyfly.actuator.wiring import build_actuator_routes, install_health_indicators agg = HealthAggregator() @@ -300,7 +300,7 @@ def _collect_context_routes() -> list[Route]: # NOTE: ``create_app`` is typically called BEFORE the ApplicationContext # has started (the startup happens inside the ASGI ``lifespan`` # function). At this point user / auto-configuration beans have - # been *registered* but not *instantiated*, so the eager loop only + # been *registered* but not *instantiated*, so the eager scan only # finds indicators that were attached as static singletons. # # The remaining indicators are picked up by ``_install_indicators`` @@ -308,16 +308,7 @@ def _collect_context_routes() -> list[Route]: # the time on_startup fires, the lifespan has already triggered # ``PyFlyApplication.startup()`` and every bean has been built. def _install_indicators() -> None: - if context is None: - return - seen = set(agg._indicators.keys()) # noqa: SLF001 — intentional, indicator names - for cls, reg in context.container._registrations.items(): - if reg.instance is not None and isinstance(reg.instance, HealthIndicator): - indicator_name = reg.name or cls.__name__ - if indicator_name in seen: - continue - agg.add_indicator(indicator_name, reg.instance) - seen.add(indicator_name) + install_health_indicators(context, agg) _install_indicators() _extra_post_start.append(_install_indicators) @@ -380,7 +371,8 @@ def _install_indicators() -> None: # Reuse health aggregator from actuator, or create one for admin health_agg = agg if health_agg is None: - from pyfly.actuator.health import HealthAggregator, HealthIndicator + from pyfly.actuator.health import HealthAggregator + from pyfly.actuator.wiring import install_health_indicators health_agg = HealthAggregator() @@ -388,15 +380,7 @@ def _install_admin_indicators(_agg: HealthAggregator = health_agg) -> None: # HealthIndicator beans are only instantiated during start(); # rescan post-start so the admin health view isn't a frozen empty # pre-startup snapshot when the actuator is disabled (audit #70). - if context is None: - return - seen = set(_agg._indicators.keys()) # noqa: SLF001 - for cls, reg in context.container._registrations.items(): - if reg.instance is not None and isinstance(reg.instance, HealthIndicator): - name = reg.name or cls.__name__ - if name not in seen: - _agg.add_indicator(name, reg.instance) - seen.add(name) + install_health_indicators(context, _agg) _install_admin_indicators() _extra_post_start.append(_install_admin_indicators) diff --git a/tests/actuator/test_health_indicators_wiring.py b/tests/actuator/test_health_indicators_wiring.py index b48c678c..2a1ea135 100644 --- a/tests/actuator/test_health_indicators_wiring.py +++ b/tests/actuator/test_health_indicators_wiring.py @@ -11,14 +11,23 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -"""Tests for health-indicator wiring: protocol conformance, aggregation, show-details.""" +"""Tests for health-indicator wiring: protocol conformance, aggregation, show-details, +and the public container scan (``install_health_indicators``).""" from __future__ import annotations import pytest from starlette.testclient import TestClient -from pyfly.actuator.health import HealthIndicator, HealthResult, HealthStatus, aggregate_status +from pyfly.actuator.health import ( + HealthAggregator, + HealthIndicator, + HealthResult, + HealthStatus, + ProbeGroup, + aggregate_status, +) +from pyfly.actuator.wiring import install_health_indicators from pyfly.context.application_context import ApplicationContext from pyfly.core.config import Config from pyfly.cqrs.actuator.health import CqrsHealthIndicator @@ -139,3 +148,91 @@ async def test_show_details_never_hides_details(self): comp = body["components"]["_OutOfServiceIndicator"] assert comp["status"] == "OUT_OF_SERVICE" assert "details" not in comp + + +class _UpIndicator: + async def health(self) -> HealthStatus: + return HealthStatus(status="UP") + + +class _DownIndicator: + async def health(self) -> HealthStatus: + return HealthStatus(status="DOWN", details={"reason": "offline"}) + + +class _NotAnIndicator: + pass + + +class TestInstallHealthIndicators: + @pytest.mark.asyncio + async def test_scans_started_context_for_indicator_beans(self): + ctx = ApplicationContext(Config({})) + ctx.register_bean(_UpIndicator) + ctx.register_bean(_NotAnIndicator) + await ctx.start() + + agg = HealthAggregator() + install_health_indicators(ctx, agg) + + assert agg.has_indicator("_UpIndicator") + assert not agg.has_indicator("_NotAnIndicator") + result = await agg.check() + assert result.status == "UP" + assert "_UpIndicator" in result.components + + def test_none_context_is_a_no_op(self): + agg = HealthAggregator() + install_health_indicators(None, agg) + assert not agg.has_indicator("_UpIndicator") + + def test_bean_name_wins_over_class_name(self): + ctx = ApplicationContext(Config({})) + ctx.container.register_instance(_UpIndicator, _UpIndicator(), name="database_health") + + agg = HealthAggregator() + install_health_indicators(ctx, agg) + + assert agg.has_indicator("database_health") + assert not agg.has_indicator("_UpIndicator") + + @pytest.mark.asyncio + async def test_rescan_is_idempotent_and_preserves_existing_registration(self): + ctx = ApplicationContext(Config({})) + ctx.container.register_instance(_DownIndicator, _DownIndicator(), name="db") + + agg = HealthAggregator() + pre_registered = _UpIndicator() + agg.add_indicator("db", pre_registered, groups={ProbeGroup.READINESS}) + install_health_indicators(ctx, agg) + install_health_indicators(ctx, agg) + + # The pre-registered "db" indicator (UP, readiness-only) is kept: the + # scanned DOWN indicator of the same name must not displace it. + result = await agg.check() + assert result.status == "UP" + liveness = await agg.check_liveness() + assert "db" not in liveness.components + + @pytest.mark.asyncio + async def test_groups_apply_to_scanned_indicators(self): + ctx = ApplicationContext(Config({})) + ctx.container.register_instance(_DownIndicator, _DownIndicator(), name="db") + + agg = HealthAggregator() + install_health_indicators(ctx, agg, groups={ProbeGroup.READINESS}) + + readiness = await agg.check_readiness() + assert readiness.status == "DOWN" + liveness = await agg.check_liveness() + assert "db" not in liveness.components + assert liveness.status == "UP" + + def test_uninstantiated_beans_are_skipped(self): + ctx = ApplicationContext(Config({})) + ctx.register_bean(_UpIndicator) # registered, never resolved -> no instance + + agg = HealthAggregator() + install_health_indicators(ctx, agg) + + assert not agg.has_indicator("_UpIndicator") diff --git a/uv.lock b/uv.lock index 64ea0222..ba0246ba 100644 --- a/uv.lock +++ b/uv.lock @@ -2160,7 +2160,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.6.97" +version = "26.6.98" source = { editable = "." } dependencies = [ { name = "pydantic" },