From 3e9155e290efe681225a192727ff953707cdde43 Mon Sep 17 00:00:00 2001 From: Sean Teramae Date: Thu, 11 Jun 2026 14:58:02 -0700 Subject: [PATCH 1/3] feat(platform): Plugins API Signed-off-by: Sean Teramae --- .../src/nemo_platform_plugin/discovery.py | 71 +++++++++++++------ .../src/nemo_platform_plugin/interface.py | 3 + .../src/nmp/common/auth/middleware.py | 1 + .../src/nmp/platform_runner/health.py | 37 ++++++++++ .../src/nmp/platform_runner/schemas.py | 12 ++++ 5 files changed, 104 insertions(+), 20 deletions(-) diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/discovery.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/discovery.py index e5bf6a4b25..1d03628307 100644 --- a/packages/nemo_platform_plugin/src/nemo_platform_plugin/discovery.py +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/discovery.py @@ -108,6 +108,25 @@ CUSTOMIZATION_CONTRIBUTORS_GROUP = "nemo.customization.contributors" +# Maps entry-point group → short surface label shown in manifests / UI. +_GROUP_TO_SURFACE: dict[str, str] = { + "nemo.services": "service", + "nemo.cli": "cli", + "nemo.jobs": "jobs", + "nemo.functions": "functions", + "nemo.controllers": "controller", + "nemo.sdk": "sdk", + "nemo.mcp": "mcp", + "nemo.studio": "studio", + "nemo.skills": "skills", + "nemo.docs": "docs", + "nemo.executors": "executor", + "nemo.inference_middleware": "middleware", + "nemo.customization.contributors": "customization", + "nemo.seed": "seed", + "nemo.authz": "authz", +} + def _manifest_plugin_name(group: str, entry_point_name: str) -> str: if group in _DOT_SCOPED_GROUPS: @@ -206,34 +225,46 @@ def discover_manifests() -> dict[str, PluginManifest]: No ``nemo.plugins`` entry point is required. Each unique name found across any surface group becomes one :class:`~nemo_platform_plugin.interface.PluginManifest`. ``version`` and ``description`` are read from the installing distribution's - package metadata (``Version`` / ``Summary`` fields). + package metadata (``Version`` / ``Summary`` fields). ``surfaces`` is the + sorted list of short surface labels the plugin contributes to (e.g. + ``["cli", "mcp", "service"]``). Entry-point values are **not loaded** — this function is cheap and has no import side-effects. """ - manifests: dict[str, PluginManifest] = {} + # First pass: collect metadata and surfaces per plugin name. + plugin_meta: dict[str, tuple[str, str]] = {} # name → (version, description) + plugin_surfaces: dict[str, set[str]] = {} for group in _ALL_SURFACE_GROUPS: + surface = _GROUP_TO_SURFACE.get(group, group) for ep in discover_entry_points(group).values(): plugin_name = _manifest_plugin_name(group, ep.name) - if plugin_name in manifests: - continue - try: - dist = ep.dist - # ``dist.metadata`` is ``email.message.Message``-compatible - # and supports ``.get`` at runtime; ty's stub for - # ``importlib.metadata.PackageMetadata`` doesn't expose it. - version = dist.metadata.get("Version", "") if dist is not None else "" # ty: ignore[unresolved-attribute] - description = dist.metadata.get("Summary", "") if dist is not None else "" # ty: ignore[unresolved-attribute] - except Exception: - version = "" - description = "" - manifests[plugin_name] = PluginManifest( - name=plugin_name, - version=version, - description=description, - ) - logger.debug("Discovered plugin %r (v%s) via %r", plugin_name, version, group) + if plugin_name not in plugin_meta: + try: + dist = ep.dist + # ``dist.metadata`` is ``email.message.Message``-compatible + # and supports ``.get`` at runtime; ty's stub for + # ``importlib.metadata.PackageMetadata`` doesn't expose it. + version = dist.metadata.get("Version", "") if dist is not None else "" # ty: ignore[unresolved-attribute] + description = dist.metadata.get("Summary", "") if dist is not None else "" # ty: ignore[unresolved-attribute] + except Exception: + version = "" + description = "" + plugin_meta[plugin_name] = (version, description) + plugin_surfaces[plugin_name] = set() + plugin_surfaces[plugin_name].add(surface) + + manifests: dict[str, PluginManifest] = {} + for plugin_name, (version, description) in plugin_meta.items(): + surfaces = sorted(plugin_surfaces[plugin_name]) + manifests[plugin_name] = PluginManifest( + name=plugin_name, + version=version, + description=description, + surfaces=surfaces, + ) + logger.debug("Discovered plugin %r (v%s) surfaces=%r", plugin_name, version, surfaces) return manifests diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/interface.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/interface.py index 8e35682316..65592debbe 100644 --- a/packages/nemo_platform_plugin/src/nemo_platform_plugin/interface.py +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/interface.py @@ -21,8 +21,11 @@ class PluginManifest: name: Entry-point key (e.g. ``"example"``). version: Distribution ``Version`` field, or ``""`` if unavailable. description: Distribution ``Summary`` field, or ``""`` if unavailable. + surfaces: Sorted list of surface labels this plugin contributes to + (e.g. ``["cli", "mcp", "service"]``). """ name: str version: str description: str = field(default="") + surfaces: list[str] = field(default_factory=list) diff --git a/packages/nmp_common/src/nmp/common/auth/middleware.py b/packages/nmp_common/src/nmp/common/auth/middleware.py index e873fd4a5a..4772b21019 100644 --- a/packages/nmp_common/src/nmp/common/auth/middleware.py +++ b/packages/nmp_common/src/nmp/common/auth/middleware.py @@ -56,6 +56,7 @@ def _embedded_pdp_base_url_hint(config: AuthConfig) -> str: "/health/live", "/health/ready", "/metrics", + "/plugins", "/apis/auth/discovery", # Discovery endpoint for CLI/SDK } diff --git a/packages/nmp_platform_runner/src/nmp/platform_runner/health.py b/packages/nmp_platform_runner/src/nmp/platform_runner/health.py index 2037ba584c..1d06737fb2 100644 --- a/packages/nmp_platform_runner/src/nmp/platform_runner/health.py +++ b/packages/nmp_platform_runner/src/nmp/platform_runner/health.py @@ -6,8 +6,10 @@ from __future__ import annotations import logging +from typing import Literal from fastapi import APIRouter, HTTPException +from nemo_platform_plugin.discovery import discover_manifests from nmp.common.controller import ControllerManager from nmp.common.service import Service from nmp.platform_runner.schemas import ( @@ -18,6 +20,8 @@ HealthReadyResponse, NotReadyServiceInfo, PlatformStatusResponse, + PluginInfo, + PluginsResponse, ServiceStatusBreakdown, ) from nmp.platform_runner.version import get_platform_version, get_revision @@ -85,6 +89,39 @@ async def status() -> PlatformStatusResponse: controllers=ControllerStatusBreakdown(healthy=all_healthy, status=controllers), ) + @router.get("/plugins", operation_id="platform_plugins", response_model=PluginsResponse) + async def plugins() -> PluginsResponse: + + manifests = discover_manifests() + ready, not_ready_list = await _get_service_status_breakdown(services) + ready_set = set(ready) + not_ready_set = {item["name"] for item in not_ready_list} + + plugin_list: list[PluginInfo] = [] + for manifest in manifests.values(): + if manifest.name in ready_set: + status: Literal["ready", "not_ready", "unknown", "installed"] = "ready" + elif manifest.name in not_ready_set: + status = "not_ready" + elif "service" not in manifest.surfaces: + # Plugin has no service entry point — contributes CLI, MCP, Studio, etc. + # There is no service health to check. + status = "installed" + else: + status = "unknown" + plugin_list.append( + PluginInfo( + name=manifest.name, + version=manifest.version, + description=manifest.description, + status=status, + surfaces=manifest.surfaces, + ) + ) + + plugin_list.sort(key=lambda p: p.name) + return PluginsResponse(plugins=plugin_list) + @router.get("/health/live", operation_id="platform_health_live", response_model=HealthLiveResponse) async def health_live() -> HealthLiveResponse: return HealthLiveResponse() diff --git a/packages/nmp_platform_runner/src/nmp/platform_runner/schemas.py b/packages/nmp_platform_runner/src/nmp/platform_runner/schemas.py index d265737805..18eb436093 100644 --- a/packages/nmp_platform_runner/src/nmp/platform_runner/schemas.py +++ b/packages/nmp_platform_runner/src/nmp/platform_runner/schemas.py @@ -48,3 +48,15 @@ class PlatformStatusResponse(BaseModel): controllers: ControllerStatusBreakdown = Field( default_factory=lambda: ControllerStatusBreakdown(healthy=True, status={}) ) + + +class PluginInfo(BaseModel): + name: str + version: str + description: str + status: Literal["ready", "not_ready", "unknown", "installed"] + surfaces: list[str] = Field(default_factory=list) + + +class PluginsResponse(BaseModel): + plugins: list[PluginInfo] From b1e689d97fa2ee017ecdb95969fff43c82a028a2 Mon Sep 17 00:00:00 2001 From: Sean Teramae Date: Thu, 11 Jun 2026 15:43:06 -0700 Subject: [PATCH 2/3] fix lint Signed-off-by: Sean Teramae --- .../src/nmp/platform_runner/health.py | 11 +++++++++++ packages/nmp_platform_runner/tests/test_health.py | 14 ++++++++++++++ script/generate_openapi_spec.py | 11 +++-------- .../src/nmp/core/auth/assets/static-authz.yaml | 1 + 4 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 packages/nmp_platform_runner/tests/test_health.py diff --git a/packages/nmp_platform_runner/src/nmp/platform_runner/health.py b/packages/nmp_platform_runner/src/nmp/platform_runner/health.py index 1d06737fb2..e6d2191d69 100644 --- a/packages/nmp_platform_runner/src/nmp/platform_runner/health.py +++ b/packages/nmp_platform_runner/src/nmp/platform_runner/health.py @@ -26,6 +26,7 @@ ) from nmp.platform_runner.version import get_platform_version, get_revision from opentelemetry.semconv.attributes import service_attributes +from starlette.routing import Route logger = logging.getLogger(__name__) @@ -136,3 +137,13 @@ async def health_ready() -> HealthReadyResponse: return HealthReadyResponse() return router + + +def get_platform_health_endpoint_paths(services: list[Service] | None = None) -> tuple[str, ...]: + """Return OpenAPI path strings registered on the platform health router.""" + router = create_platform_health_router(services or []) + paths: set[str] = set() + for route in router.routes: + if isinstance(route, Route): + paths.add(route.path) + return tuple(sorted(paths)) diff --git a/packages/nmp_platform_runner/tests/test_health.py b/packages/nmp_platform_runner/tests/test_health.py new file mode 100644 index 0000000000..0e6929a9e7 --- /dev/null +++ b/packages/nmp_platform_runner/tests/test_health.py @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from nmp.platform_runner.health import get_platform_health_endpoint_paths + + +def test_get_platform_health_endpoint_paths(): + assert get_platform_health_endpoint_paths() == ( + "/cluster-info", + "/health/live", + "/health/ready", + "/plugins", + "/status", + ) diff --git a/script/generate_openapi_spec.py b/script/generate_openapi_spec.py index 275158f528..f4746e78a8 100644 --- a/script/generate_openapi_spec.py +++ b/script/generate_openapi_spec.py @@ -18,6 +18,7 @@ import yaml from nmp.common.api.utils import clear_query_param_schemas, register_query_param_schemas from nmp.common.version import platform_api_version +from nmp.platform_runner.health import get_platform_health_endpoint_paths from uvicorn.importer import import_from_string from .openapi_helper.openapi_tools import ( @@ -425,14 +426,8 @@ def apply_schema_fixes(spec_files: List[str], apply_reorder: bool = True) -> Non """Apply schema fixes to a list of OpenAPI spec files.""" print_green("=== Applying fixes to OpenAPI schemas ===") # Endpoints stripped from the public OpenAPI spec (not exposed in SDK). - # Includes health/status and internal endpoints. - health_endpoints = [ - "/status", - "/metrics", - "/cluster-info", - "/health/live", - "/health/ready", - ] + # Platform health routes come from health.py; /metrics is from observability tooling. + health_endpoints = list(get_platform_health_endpoint_paths()) + ["/metrics"] for spec_file in spec_files: if os.path.exists(spec_file): diff --git a/services/core/auth/src/nmp/core/auth/assets/static-authz.yaml b/services/core/auth/src/nmp/core/auth/assets/static-authz.yaml index be49cf053d..27ac989ad3 100644 --- a/services/core/auth/src/nmp/core/auth/assets/static-authz.yaml +++ b/services/core/auth/src/nmp/core/auth/assets/static-authz.yaml @@ -299,6 +299,7 @@ authz: - models.adapters.read - models.list - models.read + - plugins.list - projects.list - projects.read - secrets.list From d1d6c4eda2067333cbcef552ee71c128aa4c8497 Mon Sep 17 00:00:00 2001 From: Sean Teramae Date: Thu, 11 Jun 2026 17:59:57 -0700 Subject: [PATCH 3/3] fix auth Signed-off-by: Sean Teramae --- services/core/auth/src/nmp/core/auth/assets/static-authz.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/services/core/auth/src/nmp/core/auth/assets/static-authz.yaml b/services/core/auth/src/nmp/core/auth/assets/static-authz.yaml index cefda5d08a..81aa550ef7 100644 --- a/services/core/auth/src/nmp/core/auth/assets/static-authz.yaml +++ b/services/core/auth/src/nmp/core/auth/assets/static-authz.yaml @@ -299,7 +299,6 @@ authz: - models.adapters.read - models.list - models.read - - plugins.list - projects.list - projects.read - secrets.list