diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/registry.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/registry.py index 01820465be..4f74d7784c 100644 --- a/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/registry.py +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/registry.py @@ -31,6 +31,14 @@ class ExecutorSpec: config: dict[str, Any] +@dataclass(frozen=True) +class UnavailableExecutor: + """A configured executor that was skipped because its backend is unavailable.""" + + backend: str + reason: str + + class ExecutorNotFoundError(KeyError): """Raised when no executor matches the requested name.""" @@ -42,9 +50,16 @@ class UnknownBackendTypeError(KeyError): class ExecutorRegistry: """Maps executor names to configured DeploymentBackend singletons.""" - def __init__(self, executors: dict[str, DeploymentBackend], *, default_executor: str | None) -> None: + def __init__( + self, + executors: dict[str, DeploymentBackend], + *, + default_executor: str | None, + unavailable: dict[str, UnavailableExecutor] | None = None, + ) -> None: self._executors = executors self._default_executor = default_executor + self._unavailable = unavailable or {} @classmethod def from_config( @@ -59,6 +74,7 @@ def from_config( if len({spec.name for spec in specs}) != len(specs): raise ValueError("Duplicate executor names are not allowed.") executors: dict[str, DeploymentBackend] = {} + unavailable: dict[str, UnavailableExecutor] = {} try: for spec in specs: if spec.backend not in classes: @@ -68,10 +84,11 @@ def from_config( except MissingBackendDependencyError as exc: # Capability missing (optional packaging extra, unreachable Docker # daemon, etc.): skip just that executor so the deployments service - # can still boot. Resolving a skipped name later raises - # ExecutorNotFoundError. A configured default_executor that failed - # to register still fails fast below — silent clearing hid config - # mistakes and made debugging harder. + # can still boot. Remember it so resolving the name later explains + # the missing backend instead of looking like a typo. A configured + # default_executor that failed to register still fails fast below; + # silent clearing hid config mistakes and made debugging harder. + unavailable[spec.name] = UnavailableExecutor(backend=spec.backend, reason=str(exc)) logger.warning( "Skipping executor '%s': backend '%s' is unavailable (%s)", spec.name, @@ -87,7 +104,7 @@ def from_config( for backend in executors.values(): backend.shutdown() raise - return cls(executors, default_executor=default_executor) + return cls(executors, default_executor=default_executor, unavailable=unavailable) @classmethod def empty(cls) -> Self: @@ -99,6 +116,12 @@ def resolve(self, name: str | None = None) -> DeploymentBackend: if executor_name is None: raise ExecutorNotFoundError("No executor specified and no default_executor configured.") if executor_name not in self._executors: + skipped = self._unavailable.get(executor_name) + if skipped is not None: + raise ExecutorNotFoundError( + f"Executor '{executor_name}' is configured but its backend " + f"'{skipped.backend}' is unavailable: {skipped.reason}" + ) raise ExecutorNotFoundError(f"Executor '{executor_name}' is not registered.") return self._executors[executor_name] diff --git a/plugins/nemo-deployments/tests/unit/test_registry.py b/plugins/nemo-deployments/tests/unit/test_registry.py index 406cc478c9..8262412e8c 100644 --- a/plugins/nemo-deployments/tests/unit/test_registry.py +++ b/plugins/nemo-deployments/tests/unit/test_registry.py @@ -115,10 +115,36 @@ def test_missing_executor_raises(backend_classes: dict[str, type[DeploymentBacke [ExecutorSpec(name="a", backend="docker", config={})], backend_classes=backend_classes, ) - with pytest.raises(ExecutorNotFoundError): + with pytest.raises(ExecutorNotFoundError, match="'missing' is not registered"): registry.resolve("missing") +def test_unavailable_executor_reports_distinct_error( + backend_classes: dict[str, type[DeploymentBackend]], +) -> None: + # A configured executor whose backend was skipped must resolve to a distinct, + # actionable error that names the backend, not the generic 'not registered' + # message used for a genuinely unknown name. + sdk = AsyncNeMoPlatform(base_url="http://localhost:8080") + classes = {**backend_classes, "sandbox": _MissingDepBackend} + registry = ExecutorRegistry.from_config( + sdk, + [ + ExecutorSpec(name="ok", backend="docker", config={}), + ExecutorSpec(name="sandbox-local", backend="sandbox", config={}), + ], + backend_classes=classes, + ) + with pytest.raises(ExecutorNotFoundError) as excinfo: + registry.resolve("sandbox-local") + message = str(excinfo.value) + assert "is not registered" not in message + assert "configured but" in message + assert "unavailable" in message + assert "sandbox" in message + assert "openshell extra not installed" in message + + def test_unknown_backend_type_raises() -> None: sdk = AsyncNeMoPlatform(base_url="http://localhost:8080") with pytest.raises(UnknownBackendTypeError): diff --git a/pyproject.toml b/pyproject.toml index 80a5caff8d..274baa99a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -213,6 +213,11 @@ core-services = [ # Includes core services plus all application services functional-services = [ { include-group = "core-services" }, + # Ship the openshell deployment SDK in the nmp-api image only. The extra carries + # platform-restricted wheels (manylinux_2_39 / macOS 13 arm64) and is deliberately + # kept out of `enabled-plugins` so bare `uv sync` on unsupported hosts (older glibc, + # older macOS) does not hard-fail. The api image runs on a compatible base. + "nemo-deployments-plugin[openshell]", "nmp-studio", "nmp-guardrails", "nemo-data-designer-plugin", diff --git a/uv.lock b/uv.lock index 3f40ddca0a..b9c17fcc81 100644 --- a/uv.lock +++ b/uv.lock @@ -6452,7 +6452,7 @@ functional-services = [ { name = "nemo-automodel-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nemo-customizer-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nemo-data-designer-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nemo-deployments-plugin", extra = ["docker", "k8s"], marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nemo-deployments-plugin", extra = ["docker", "k8s", "openshell"], marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nemo-eval-author-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nemo-evaluator-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "nemo-experimentalist-plugin", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, @@ -6673,6 +6673,7 @@ functional-services = [ { name = "nemo-customizer-plugin", editable = "plugins/nemo-customizer" }, { name = "nemo-data-designer-plugin", editable = "plugins/nemo-data-designer" }, { name = "nemo-deployments-plugin", extras = ["docker", "k8s"], editable = "plugins/nemo-deployments" }, + { name = "nemo-deployments-plugin", extras = ["openshell"], editable = "plugins/nemo-deployments" }, { name = "nemo-eval-author-plugin", marker = "python_full_version < '3.14'", editable = "plugins/nemo-eval-author" }, { name = "nemo-evaluator-plugin", editable = "plugins/nemo-evaluator" }, { name = "nemo-experimentalist-plugin", marker = "python_full_version < '3.14'", editable = "plugins/nemo-experimentalist" },