diff --git a/plugins/nemo-deployments/README.md b/plugins/nemo-deployments/README.md new file mode 100644 index 0000000000..7623249ac5 --- /dev/null +++ b/plugins/nemo-deployments/README.md @@ -0,0 +1,33 @@ +# NeMo Deployments Plugin + +Substrate-agnostic deployment lifecycle for the NeMo Platform. This plugin provides +entity schemas, CRUD APIs, a `DeploymentBackend` ABC, and an executor registry. + +**Scope (this ticket):** scaffold only — entity types, v1 CRUD routes, backend contract, +and executor registry. Docker/K8s backends and the reconcile controller land in follow-on +tickets (756–758). + +## Prerequisites + +- NeMo Platform workspace bootstrapped (`make bootstrap`, `nemo setup`) +- Plugin enabled in root `pyproject.toml` (`enabled-plugins` includes `deployments`) + +## API base path + +`/apis/deployments/v1/workspaces/{workspace}/...` + +Cross-workspace bulk queries use the entity-store sentinel workspace ``-``: + +``GET /apis/deployments/v1/workspaces/-/deployments?status_in=pending,starting`` + +## Next steps + +- **756 / 757:** Docker and Kubernetes `DeploymentBackend` implementations +- **758:** Reconcile controller wiring status writes and backend lifecycle + +## Tests + +```bash +uv sync +uv run pytest plugins/nemo-deployments/tests/unit -v +``` diff --git a/plugins/nemo-deployments/pyproject.toml b/plugins/nemo-deployments/pyproject.toml new file mode 100644 index 0000000000..c4f9f8482e --- /dev/null +++ b/plugins/nemo-deployments/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "nemo-deployments-plugin" +version = "0.1.0" +description = "NeMo Deployments Plugin — substrate-agnostic deployment lifecycle on the NeMo Platform." +readme = "README.md" +requires-python = ">=3.11,<3.14" +dependencies = [ + "fastapi>=0.115", + "nemo-platform", + "nemo-platform-plugin", + "pydantic>=2.10.6", +] + +[project.entry-points."nemo.services"] +deployments = "nemo_deployments_plugin.service:DeploymentsService" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/nemo_deployments_plugin"] + +[tool.uv.sources] +nemo-platform = { workspace = true } +nemo-platform-plugin = { workspace = true } + +[dependency-groups] +dev = ["pytest>=8.3.4", "pytest-asyncio>=0.25.3", "httpx>=0.27", "fastapi>=0.115"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +pythonpath = ["src", "tests/unit"] + +[tool.nemo.openapi] diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v1/dependencies.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v1/dependencies.py new file mode 100644 index 0000000000..9471f96780 --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v1/dependencies.py @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""FastAPI dependencies for the deployments plugin API.""" + +from __future__ import annotations + +from fastapi import HTTPException, Request +from nemo_platform_plugin.entity_client import get_entity_client + +__all__ = ["get_entity_client", "require_service_principal"] + +_PRINCIPAL_ID_HEADER = "X-NMP-Principal-Id" + + +def require_service_principal(request: Request) -> None: + """Restrict controller-only status writes to service principals.""" + principal_id = request.headers.get(_PRINCIPAL_ID_HEADER, "") + if not principal_id.startswith("service:"): + raise HTTPException( + status_code=403, + detail="Status updates require a service principal.", + ) diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v1/deployment_configs.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v1/deployment_configs.py new file mode 100644 index 0000000000..2e92a8bc86 --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v1/deployment_configs.py @@ -0,0 +1,137 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""DeploymentConfig CRUD routes.""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, HTTPException, Query +from nemo_deployments_plugin.api.v1.dependencies import get_entity_client +from nemo_deployments_plugin.entities import DeploymentConfig +from nemo_deployments_plugin.schema import ( + CreateDeploymentConfigRequest, + DeploymentConfigFilter, + DeploymentConfigPage, +) +from nemo_deployments_plugin.validation import ( + PrerequisiteCycleError, + build_existing_prerequisite_map, + detect_prerequisite_cycle, + prerequisite_names, +) +from nemo_platform_plugin.api.filters import make_filter_obj_dep +from nemo_platform_plugin.entity_client import NemoEntitiesClient, NemoEntityConflictError, NemoEntityNotFoundError +from nemo_platform_plugin.schema import PaginationData + +logger = logging.getLogger(__name__) + +router = APIRouter() + +_config_filter_dep = make_filter_obj_dep(DeploymentConfigFilter) + + +async def _list_all_deployment_configs( + entity_client: NemoEntitiesClient, + workspace: str, +) -> list[DeploymentConfig]: + """Page through all deployment configs for prerequisite graph validation.""" + page = 1 + configs: list[DeploymentConfig] = [] + while True: + result = await entity_client.list( + DeploymentConfig, + workspace=workspace, + page=page, + page_size=100, + ) + configs.extend(result.data) + if result.pagination is None or page >= result.pagination.total_pages: + break + page += 1 + return configs + + +@router.post("/deployment-configs", response_model=DeploymentConfig, status_code=201, tags=["Deployment Configs"]) +async def create_deployment_config( + workspace: str, + body: CreateDeploymentConfigRequest, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> DeploymentConfig: + prereq_names = prerequisite_names(body.prerequisites) + try: + existing_configs = await _list_all_deployment_configs(entity_client, workspace) + existing_map = build_existing_prerequisite_map(existing_configs) + detect_prerequisite_cycle( + config_name=body.name, + prerequisites=prereq_names, + existing=existing_map, + ) + except PrerequisiteCycleError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + config = DeploymentConfig( + name=body.name, + workspace=workspace, + **body.model_dump(exclude={"name"}, exclude_none=True), + ) + try: + return await entity_client.create(config) + except NemoEntityConflictError as exc: + raise HTTPException( + status_code=409, + detail=f"DeploymentConfig '{body.name}' already exists in workspace '{workspace}'.", + ) from exc + + +@router.get("/deployment-configs", response_model=DeploymentConfigPage, tags=["Deployment Configs"]) +async def list_deployment_configs( + workspace: str, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + sort: str = Query(default="-created_at"), + filter: DeploymentConfigFilter = Depends(_config_filter_dep), + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> DeploymentConfigPage: + filter_dict = filter if isinstance(filter, dict) else filter.model_dump(exclude_none=True) + result = await entity_client.list( + DeploymentConfig, + workspace=workspace, + page=page, + page_size=page_size, + sort=sort, + filter_obj=filter_dict or None, + ) + pagination = PaginationData.model_validate(result.pagination.model_dump()) if result.pagination else None + return DeploymentConfigPage(data=result.data, pagination=pagination, sort=sort, filter=filter) + + +@router.get("/deployment-configs/{name}", response_model=DeploymentConfig, tags=["Deployment Configs"]) +async def get_deployment_config( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> DeploymentConfig: + try: + return await entity_client.get(DeploymentConfig, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=f"DeploymentConfig '{name}' not found in workspace '{workspace}'.", + ) from exc + + +@router.delete("/deployment-configs/{name}", status_code=204, tags=["Deployment Configs"]) +async def delete_deployment_config( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> None: + try: + await entity_client.delete(DeploymentConfig, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=f"DeploymentConfig '{name}' not found in workspace '{workspace}'.", + ) from exc diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v1/deployments.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v1/deployments.py new file mode 100644 index 0000000000..e6955e2f60 --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v1/deployments.py @@ -0,0 +1,148 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Deployment CRUD routes.""" + +from __future__ import annotations + +import logging +from typing import cast + +from fastapi import APIRouter, Depends, HTTPException, Query +from nemo_deployments_plugin.api.v1.dependencies import get_entity_client +from nemo_deployments_plugin.entities import Deployment, DeploymentConfig, DeploymentStatus +from nemo_deployments_plugin.schema import CreateDeploymentRequest, DeploymentFilter, DeploymentPage +from nemo_platform_plugin.api.filters import make_filter_obj_dep +from nemo_platform_plugin.entity_client import NemoEntitiesClient, NemoEntityConflictError, NemoEntityNotFoundError +from nemo_platform_plugin.filter_ops import ComparisonOperation, FilterOperator +from nemo_platform_plugin.schema import PaginationData + +logger = logging.getLogger(__name__) + +router = APIRouter() + +_deployment_filter_dep = make_filter_obj_dep(DeploymentFilter) + +_VALID_DEPLOYMENT_STATUSES: frozenset[str] = frozenset( + {"PENDING", "STARTING", "READY", "SUCCEEDED", "FAILED", "LOST", "DELETING"} +) + + +def _parse_status_in(status_in: str | None) -> list[DeploymentStatus]: + if not status_in: + return [] + values = [part.strip().upper() for part in status_in.split(",") if part.strip()] + invalid = [value for value in values if value not in _VALID_DEPLOYMENT_STATUSES] + if invalid: + raise HTTPException( + status_code=400, + detail=f"Invalid deployment status values: {', '.join(invalid)}", + ) + return cast(list[DeploymentStatus], values) + + +@router.post("/deployments", response_model=Deployment, status_code=201, tags=["Deployments"]) +async def create_deployment( + workspace: str, + body: CreateDeploymentRequest, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> Deployment: + try: + await entity_client.get(DeploymentConfig, name=body.deployment_config_name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=(f"DeploymentConfig '{body.deployment_config_name}' not found in workspace '{workspace}'."), + ) from exc + + deployment = Deployment( + name=body.name, + workspace=workspace, + deployment_config_name=body.deployment_config_name, + desired_state=body.desired_state, + executor=body.executor, + status="PENDING", + ) + try: + return await entity_client.create(deployment) + except NemoEntityConflictError as exc: + raise HTTPException( + status_code=409, + detail=f"Deployment '{body.name}' already exists in workspace '{workspace}'.", + ) from exc + + +@router.get("/deployments", response_model=DeploymentPage, tags=["Deployments"]) +async def list_deployments( + workspace: str, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + sort: str = Query(default="-created_at"), + status_in: str | None = Query( + default=None, + description="Comma-separated deployment statuses for bulk reconciler queries.", + ), + filter: DeploymentFilter = Depends(_deployment_filter_dep), + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> DeploymentPage: + filter_dict = filter if isinstance(filter, dict) else filter.model_dump(exclude_none=True) + statuses = _parse_status_in(status_in) if status_in else [] + filter_operation = None + if statuses: + filter_operation = ComparisonOperation( + operator=FilterOperator.IN, + field="status", + value=statuses, + ) + result = await entity_client.list( + Deployment, + workspace=workspace, + page=page, + page_size=page_size, + sort=sort, + filter_obj=filter_dict or None, + filter_operation=filter_operation, + ) + pagination = PaginationData.model_validate(result.pagination.model_dump()) if result.pagination else None + return DeploymentPage(data=result.data, pagination=pagination, sort=sort, filter=filter) + + +@router.get("/deployments/{name}", response_model=Deployment, tags=["Deployments"]) +async def get_deployment( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> Deployment: + try: + return await entity_client.get(Deployment, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=f"Deployment '{name}' not found in workspace '{workspace}'.", + ) from exc + + +@router.delete("/deployments/{name}", status_code=204, tags=["Deployments"]) +async def delete_deployment( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> None: + try: + deployment = await entity_client.get(Deployment, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=f"Deployment '{name}' not found in workspace '{workspace}'.", + ) from exc + + deployment.status = "DELETING" + try: + await entity_client.update(deployment) + except NemoEntityNotFoundError: + logger.info("Deployment already deleted before status update") + except NemoEntityConflictError as exc: + raise HTTPException( + status_code=409, + detail=f"Deployment '{name}' is being modified concurrently.", + ) from exc diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v1/status.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v1/status.py new file mode 100644 index 0000000000..bd74234047 --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v1/status.py @@ -0,0 +1,80 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Controller-only status update routes.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from nemo_deployments_plugin.api.v1.dependencies import get_entity_client, require_service_principal +from nemo_deployments_plugin.entities import Deployment, Volume +from nemo_deployments_plugin.schema import UpdateDeploymentStatusRequest, UpdateVolumeStatusRequest +from nemo_platform_plugin.entity_client import NemoEntitiesClient, NemoEntityConflictError, NemoEntityNotFoundError + +router = APIRouter() + + +@router.put("/deployments/{name}/status", response_model=Deployment, tags=["Deployment Status"]) +async def update_deployment_status( + workspace: str, + name: str, + body: UpdateDeploymentStatusRequest, + _: None = Depends(require_service_principal), + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> Deployment: + try: + deployment = await entity_client.get(Deployment, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=f"Deployment '{name}' not found in workspace '{workspace}'.", + ) from exc + + deployment.status = body.status + deployment.status_message = body.status_message + deployment.endpoints = body.endpoints + deployment.exit_code = body.exit_code + deployment.error_details = body.error_details + if body.status_history is not None: + deployment.status_history = body.status_history + + try: + return await entity_client.update(deployment) + except NemoEntityConflictError as exc: + raise HTTPException(status_code=409, detail="Concurrent modification.") from exc + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=f"Deployment '{name}' not found in workspace '{workspace}'.", + ) from exc + + +@router.put("/volumes/{name}/status", response_model=Volume, tags=["Volume Status"]) +async def update_volume_status( + workspace: str, + name: str, + body: UpdateVolumeStatusRequest, + _: None = Depends(require_service_principal), + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> Volume: + try: + volume = await entity_client.get(Volume, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=f"Volume '{name}' not found in workspace '{workspace}'.", + ) from exc + + volume.status = body.status + volume.status_message = body.status_message + volume.error_details = body.error_details + + try: + return await entity_client.update(volume) + except NemoEntityConflictError as exc: + raise HTTPException(status_code=409, detail="Concurrent modification.") from exc + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=f"Volume '{name}' not found in workspace '{workspace}'.", + ) from exc diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v1/volumes.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v1/volumes.py new file mode 100644 index 0000000000..3203a22e06 --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/api/v1/volumes.py @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Volume CRUD routes.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query +from nemo_deployments_plugin.api.v1.dependencies import get_entity_client +from nemo_deployments_plugin.entities import Volume +from nemo_deployments_plugin.schema import CreateVolumeRequest, VolumeFilter, VolumePage +from nemo_platform_plugin.api.filters import make_filter_obj_dep +from nemo_platform_plugin.entity_client import NemoEntitiesClient, NemoEntityConflictError, NemoEntityNotFoundError +from nemo_platform_plugin.schema import PaginationData + +router = APIRouter() + +_volume_filter_dep = make_filter_obj_dep(VolumeFilter) + + +@router.post("/volumes", response_model=Volume, status_code=201, tags=["Volumes"]) +async def create_volume( + workspace: str, + body: CreateVolumeRequest, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> Volume: + volume = Volume( + name=body.name, + workspace=workspace, + status="PENDING", + **body.model_dump(exclude={"name"}, exclude_none=True), + ) + try: + return await entity_client.create(volume) + except NemoEntityConflictError as exc: + raise HTTPException( + status_code=409, + detail=f"Volume '{body.name}' already exists in workspace '{workspace}'.", + ) from exc + + +@router.get("/volumes", response_model=VolumePage, tags=["Volumes"]) +async def list_volumes( + workspace: str, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + sort: str = Query(default="-created_at"), + filter: VolumeFilter = Depends(_volume_filter_dep), + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> VolumePage: + filter_dict = filter if isinstance(filter, dict) else filter.model_dump(exclude_none=True) + result = await entity_client.list( + Volume, + workspace=workspace, + page=page, + page_size=page_size, + sort=sort, + filter_obj=filter_dict or None, + ) + pagination = PaginationData.model_validate(result.pagination.model_dump()) if result.pagination else None + return VolumePage(data=result.data, pagination=pagination, sort=sort, filter=filter) + + +@router.get("/volumes/{name}", response_model=Volume, tags=["Volumes"]) +async def get_volume( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> Volume: + try: + return await entity_client.get(Volume, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=f"Volume '{name}' not found in workspace '{workspace}'.", + ) from exc + + +@router.delete("/volumes/{name}", status_code=204, tags=["Volumes"]) +async def delete_volume( + workspace: str, + name: str, + entity_client: NemoEntitiesClient = Depends(get_entity_client), +) -> None: + try: + await entity_client.delete(Volume, name=name, workspace=workspace) + except NemoEntityNotFoundError as exc: + raise HTTPException( + status_code=404, + detail=f"Volume '{name}' not found in workspace '{workspace}'.", + ) from exc diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/base.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/base.py new file mode 100644 index 0000000000..e87e7aac64 --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/base.py @@ -0,0 +1,108 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Deployment backend ABC and status projection types.""" + +from __future__ import annotations + +import abc +from abc import abstractmethod +from dataclasses import dataclass +from typing import Any + +from nemo_deployments_plugin.types import DeploymentStatus, Endpoint, VolumeStatus +from nemo_platform import AsyncNeMoPlatform +from pydantic import BaseModel, Field + + +class BackendStatusUpdate(BaseModel): + """Status projection returned by backends — consumed by the reconciler (758).""" + + status: DeploymentStatus + status_message: str = "" + error_details: dict[str, Any] | None = None + endpoints: list[Endpoint] = Field(default_factory=list) + exit_code: int | None = None + + +class VolumeStatusUpdate(BaseModel): + status: VolumeStatus + status_message: str = "" + error_details: dict[str, Any] | None = None + + +@dataclass(frozen=True) +class LogResult: + lines: list[str] + truncated: bool = False + + +class DeploymentBackend(abc.ABC): + """Abstract substrate backend for deployment and volume lifecycle.""" + + def __init__(self, sdk: AsyncNeMoPlatform, config: dict[str, Any]) -> None: + self._sdk = sdk + self._config = config + self.init() + + def init(self) -> None: + """Optional hook for backend-specific startup.""" + + @abstractmethod + def shutdown(self) -> None: + """Release backend resources.""" + raise NotImplementedError + + @abstractmethod + async def create_deployment( + self, + *, + workspace: str, + name: str, + config_name: str, + labels: dict[str, str], + backend_config: dict[str, Any], + ) -> BackendStatusUpdate: + raise NotImplementedError + + @abstractmethod + async def read_status(self, *, workspace: str, name: str) -> BackendStatusUpdate: + raise NotImplementedError + + @abstractmethod + async def delete_deployment(self, workspace: str, name: str) -> BackendStatusUpdate: + raise NotImplementedError + + @abstractmethod + async def list_managed_deployment_names(self) -> list[str]: + raise NotImplementedError + + @abstractmethod + async def get_logs( + self, + *, + workspace: str, + name: str, + tail: int = 100, + ) -> LogResult: + raise NotImplementedError + + @abstractmethod + async def create_volume( + self, + *, + workspace: str, + name: str, + size: str, + access_modes: list[str], + backend_config: dict[str, Any], + ) -> VolumeStatusUpdate: + raise NotImplementedError + + @abstractmethod + async def read_volume_status(self, *, workspace: str, name: str) -> VolumeStatusUpdate: + raise NotImplementedError + + @abstractmethod + async def delete_volume(self, workspace: str, name: str) -> VolumeStatusUpdate: + raise NotImplementedError diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/registry.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/registry.py new file mode 100644 index 0000000000..05aacd509b --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/registry.py @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Named executor registry for deployment backends.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any, Self + +from nemo_deployments_plugin.backends.base import DeploymentBackend +from nemo_platform import AsyncNeMoPlatform + +logger = logging.getLogger(__name__) + +BACKEND_CLASSES: dict[str, type[DeploymentBackend]] = {} +"""Backend type → class map. Populated when docker/k8s backends land (756/757).""" + + +@dataclass(frozen=True) +class ExecutorSpec: + name: str + backend: str + config: dict[str, Any] + + +class ExecutorNotFoundError(KeyError): + """Raised when no executor matches the requested name.""" + + +class UnknownBackendTypeError(KeyError): + """Raised when an executor references an unknown backend type.""" + + +class ExecutorRegistry: + """Maps executor names to configured DeploymentBackend singletons.""" + + def __init__(self, executors: dict[str, DeploymentBackend], *, default_executor: str | None) -> None: + self._executors = executors + self._default_executor = default_executor + + @classmethod + def from_config( + cls, + sdk: AsyncNeMoPlatform, + specs: list[ExecutorSpec], + *, + default_executor: str | None = None, + backend_classes: dict[str, type[DeploymentBackend]] | None = None, + ) -> Self: + classes = backend_classes if backend_classes is not None else BACKEND_CLASSES + if len({spec.name for spec in specs}) != len(specs): + raise ValueError("Duplicate executor names are not allowed.") + executors: dict[str, DeploymentBackend] = {} + try: + for spec in specs: + if spec.backend not in classes: + raise UnknownBackendTypeError(f"Unknown backend type '{spec.backend}' for executor '{spec.name}'.") + executors[spec.name] = classes[spec.backend](sdk, spec.config) + if default_executor and default_executor not in executors: + raise ExecutorNotFoundError(f"default_executor '{default_executor}' is not registered.") + except Exception: + for backend in executors.values(): + backend.shutdown() + raise + return cls(executors, default_executor=default_executor) + + @classmethod + def empty(cls) -> Self: + """Registry with zero executors — valid at scaffold startup.""" + return cls({}, default_executor=None) + + def resolve(self, name: str | None = None) -> DeploymentBackend: + executor_name = name or self._default_executor + if executor_name is None: + raise ExecutorNotFoundError("No executor specified and no default_executor configured.") + if executor_name not in self._executors: + raise ExecutorNotFoundError(f"Executor '{executor_name}' is not registered.") + return self._executors[executor_name] + + def shutdown_all(self) -> None: + for name, backend in self._executors.items(): + logger.debug("Shutting down executor '%s'", name) + backend.shutdown() + + def all_backends(self) -> list[DeploymentBackend]: + return list(self._executors.values()) + + def registered_names(self) -> list[str]: + return list(self._executors.keys()) diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/config.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/config.py new file mode 100644 index 0000000000..00bd0fa28b --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/config.py @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Deployments plugin configuration.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from nemo_platform_plugin.config import NemoConfig +from pydantic import BaseModel, Field, model_validator + + +class ExecutorConfigEntry(BaseModel): + name: str = Field(description="Unique executor name used by Deployment.executor.") + backend: str = Field(description="Backend type key registered in BACKEND_CLASSES.") + config: dict[str, Any] = Field(default_factory=dict) + + +class DeploymentsConfig(NemoConfig): + plugin_name: ClassVar[str] = "deployments" + plugin_description: ClassVar[str] = "Configuration for the NeMo Platform deployments plugin." + + executors: list[ExecutorConfigEntry] = Field( + default_factory=list, + description="Named executor instances. May be empty at scaffold time.", + ) + default_executor: str | None = Field( + default=None, + description="Fallback executor when Deployment.executor is unset.", + ) + port_range_start: int = Field(default=9000, description="Default Docker port range start.") + port_range_end: int = Field(default=9100, description="Default Docker port range end.") + + @model_validator(mode="after") + def _validate_port_range(self) -> DeploymentsConfig: + if self.port_range_end < self.port_range_start: + raise ValueError( + f"port_range_end ({self.port_range_end}) must be >= port_range_start ({self.port_range_start})" + ) + return self diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/constants.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/constants.py new file mode 100644 index 0000000000..903b47ae80 --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/constants.py @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Shared constants for the deployments plugin.""" + +MANAGED_BY_LABEL = "nemo-deployments" +"""Label value backends use to tag substrate resources for orphan cleanup.""" + +ENTITY_TYPE_DEPLOYMENT_CONFIG = "deployments_deployment_config" +ENTITY_TYPE_DEPLOYMENT = "deployments_deployment" +ENTITY_TYPE_VOLUME = "deployments_volume" diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/entities.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/entities.py new file mode 100644 index 0000000000..e914bcf3a4 --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/entities.py @@ -0,0 +1,245 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Deployment plugin entity definitions and supporting Pydantic types.""" + +from __future__ import annotations + +from typing import Any, Literal + +from nemo_deployments_plugin.constants import ( + ENTITY_TYPE_DEPLOYMENT, + ENTITY_TYPE_DEPLOYMENT_CONFIG, + ENTITY_TYPE_VOLUME, +) +from nemo_deployments_plugin.types import ( + AccessMode, + DeploymentStatus, + DesiredState, + DriftRecoveryAction, + Endpoint, + RestartPolicy, + VolumeStatus, +) +from nemo_platform_plugin.entity import NemoEntity +from pydantic import BaseModel, Field + + +class EnvVar(BaseModel): + name: str + value: str | None = None + value_from: dict[str, Any] | None = Field(default=None, alias="valueFrom") + + model_config = {"populate_by_name": True} + + +class ContainerPort(BaseModel): + name: str | None = None + container_port: int = Field(alias="containerPort") + protocol: Literal["TCP", "UDP"] = "TCP" + + model_config = {"populate_by_name": True} + + +class ResourceRequirements(BaseModel): + limits: dict[str, str] = Field(default_factory=dict) + requests: dict[str, str] = Field(default_factory=dict) + + +class VolumeMount(BaseModel): + name: str + mount_path: str = Field(alias="mountPath") + read_only: bool = Field(default=False, alias="readOnly") + sub_path: str | None = Field(default=None, alias="subPath") + + model_config = {"populate_by_name": True} + + +class ExecAction(BaseModel): + command: list[str] = Field(default_factory=list) + + +class HTTPGetAction(BaseModel): + path: str = "/" + port: int | str = 8080 + scheme: Literal["HTTP", "HTTPS"] = "HTTP" + + +class TCPSocketAction(BaseModel): + port: int | str + + +class Probe(BaseModel): + exec_action: ExecAction | None = Field(default=None, alias="exec") + http_get: HTTPGetAction | None = Field(default=None, alias="httpGet") + tcp_socket: TCPSocketAction | None = Field(default=None, alias="tcpSocket") + initial_delay_seconds: int = Field(default=0, alias="initialDelaySeconds") + period_seconds: int = Field(default=10, alias="periodSeconds") + timeout_seconds: int = Field(default=1, alias="timeoutSeconds") + failure_threshold: int = Field(default=3, alias="failureThreshold") + + model_config = {"populate_by_name": True} + + +class Container(BaseModel): + name: str + image: str + command: list[str] = Field(default_factory=list) + args: list[str] = Field(default_factory=list) + env: list[EnvVar] = Field(default_factory=list) + ports: list[ContainerPort] = Field(default_factory=list) + resources: ResourceRequirements = Field(default_factory=ResourceRequirements) + volume_mounts: list[VolumeMount] = Field(default_factory=list, alias="volumeMounts") + liveness_probe: Probe | None = Field(default=None, alias="livenessProbe") + readiness_probe: Probe | None = Field(default=None, alias="readinessProbe") + + model_config = {"populate_by_name": True} + + +class ConfigFile(BaseModel): + path: str + content: str + mode: int = 0o644 + + +class Toleration(BaseModel): + key: str | None = None + operator: Literal["Equal", "Exists"] = "Equal" + value: str | None = None + effect: Literal["NoSchedule", "PreferNoSchedule", "NoExecute"] | None = None + toleration_seconds: int | None = Field(default=None, alias="tolerationSeconds") + + model_config = {"populate_by_name": True} + + +class LabelSelector(BaseModel): + match_labels: dict[str, str] = Field(default_factory=dict, alias="matchLabels") + + model_config = {"populate_by_name": True} + + +class LocalObjectReference(BaseModel): + name: str + + +class PodSecurityContext(BaseModel): + run_as_user: int | None = Field(default=None, alias="runAsUser") + run_as_group: int | None = Field(default=None, alias="runAsGroup") + fs_group: int | None = Field(default=None, alias="fsGroup") + + model_config = {"populate_by_name": True} + + +class Affinity(BaseModel): + node_affinity: dict[str, Any] | None = Field(default=None, alias="nodeAffinity") + pod_affinity: dict[str, Any] | None = Field(default=None, alias="podAffinity") + pod_anti_affinity: dict[str, Any] | None = Field(default=None, alias="podAntiAffinity") + + model_config = {"populate_by_name": True} + + +class DockerDeploymentConfig(BaseModel): + port_range_start: int = 9000 + port_range_end: int = 9100 + network: str | None = None + + +class K8sDeploymentConfig(BaseModel): + namespace: str | None = None + service_account: str | None = Field(default=None, alias="serviceAccount") + tolerations: list[Toleration] = Field(default_factory=list) + affinity: Affinity | None = None + security_context: PodSecurityContext | None = Field(default=None, alias="securityContext") + + model_config = {"populate_by_name": True} + + +class DeploymentBackendConfig(BaseModel): + docker: DockerDeploymentConfig | None = None + k8s: K8sDeploymentConfig | None = None + + +class DockerVolumeConfig(BaseModel): + driver: str = "local" + mount_point: str | None = None + + +class K8sVolumeConfig(BaseModel): + storage_class: str | None = Field(default=None, alias="storageClass") + namespace: str | None = None + + model_config = {"populate_by_name": True} + + +class VolumeBackendConfig(BaseModel): + docker: DockerVolumeConfig | None = None + k8s: K8sVolumeConfig | None = None + + +class DriftRecoveryPolicy(BaseModel): + action: DriftRecoveryAction = "recreate" + + +class Prerequisite(BaseModel): + deployment_name: str = Field( + description=( + "Name of another DeploymentConfig in the same workspace that must reach " + "a ready terminal state before this config's deployment may start." + ), + ) + + +class StatusEvent(BaseModel): + status: DeploymentStatus + message: str = "" + timestamp: str = "" + + +class DeploymentConfig(NemoEntity, entity_type=ENTITY_TYPE_DEPLOYMENT_CONFIG): + """Immutable PodSpec-shaped deployment template.""" + + containers: list[Container] = Field(default_factory=list) + init_containers: list[Container] = Field(default_factory=list, alias="initContainers") + volume_mounts: list[VolumeMount] = Field(default_factory=list, alias="volumeMounts") + config_files: list[ConfigFile] = Field(default_factory=list, alias="configFiles") + restart_policy: RestartPolicy = Field(default="Always", alias="restartPolicy") + backoff_limit: int = Field(default=6, alias="backoffLimit") + prerequisites: list[Prerequisite] = Field(default_factory=list) + drift_recovery: DriftRecoveryPolicy = Field(default_factory=DriftRecoveryPolicy, alias="driftRecovery") + labels: dict[str, str] = Field(default_factory=dict) + backend_config: DeploymentBackendConfig = Field(default_factory=DeploymentBackendConfig, alias="backendConfig") + + model_config = {"populate_by_name": True} + + +class Deployment(NemoEntity, entity_type=ENTITY_TYPE_DEPLOYMENT): + """Desired and observed deployment state.""" + + deployment_config_name: str = Field(description="Name of the DeploymentConfig entity.") + desired_state: DesiredState = Field(default="READY") + executor: str | None = Field( + default=None, + description="Named executor registry entry; falls back to plugin default_executor.", + ) + status: DeploymentStatus = Field(default="PENDING") + status_message: str = Field(default="") + endpoints: list[Endpoint] = Field(default_factory=list) + exit_code: int | None = None + error_details: dict[str, Any] | None = None + status_history: list[StatusEvent] = Field(default_factory=list) + + # Reconciler (758) enforces restart_policy vs terminal status on DeploymentConfig: + # Never → SUCCEEDED terminal; Always/OnFailure → READY while running. + + +class Volume(NemoEntity, entity_type=ENTITY_TYPE_VOLUME): + """Persistent volume request and observed state.""" + + size: str = Field(default="1Gi", description="Requested storage size (Kubernetes quantity).") + access_modes: list[AccessMode] = Field(default_factory=lambda: ["ReadWriteOnce"]) + backend_config: VolumeBackendConfig = Field(default_factory=VolumeBackendConfig, alias="backendConfig") + status: VolumeStatus = Field(default="PENDING") + status_message: str = Field(default="") + error_details: dict[str, Any] | None = None + + model_config = {"populate_by_name": True} diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/schema.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/schema.py new file mode 100644 index 0000000000..65ab6d2674 --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/schema.py @@ -0,0 +1,93 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Deployments plugin API schema definitions — request bodies and filters.""" + +from __future__ import annotations + +from typing import Any + +from nemo_deployments_plugin.entities import ( + AccessMode, + ConfigFile, + Container, + Deployment, + DeploymentBackendConfig, + DeploymentConfig, + DeploymentStatus, + DesiredState, + DriftRecoveryPolicy, + Endpoint, + Prerequisite, + RestartPolicy, + StatusEvent, + Volume, + VolumeBackendConfig, + VolumeMount, + VolumeStatus, +) +from nemo_platform_plugin.schema import NemoFilter, NemoListResponse +from pydantic import BaseModel, Field + + +class CreateDeploymentConfigRequest(BaseModel): + name: str + containers: list[Container] = Field(default_factory=list) + init_containers: list[Container] = Field(default_factory=list) + volume_mounts: list[VolumeMount] = Field(default_factory=list) + config_files: list[ConfigFile] = Field(default_factory=list) + restart_policy: RestartPolicy = "Always" + backoff_limit: int = 6 + prerequisites: list[Prerequisite] = Field(default_factory=list) + drift_recovery: DriftRecoveryPolicy | None = None + labels: dict[str, str] = Field(default_factory=dict) + backend_config: DeploymentBackendConfig = Field(default_factory=DeploymentBackendConfig) + + +class CreateDeploymentRequest(BaseModel): + name: str + deployment_config_name: str + desired_state: DesiredState = "READY" + executor: str | None = None + + +class CreateVolumeRequest(BaseModel): + name: str + size: str = "1Gi" + access_modes: list[AccessMode] = Field(default_factory=lambda: ["ReadWriteOnce"]) + backend_config: VolumeBackendConfig = Field(default_factory=VolumeBackendConfig) + + +class UpdateDeploymentStatusRequest(BaseModel): + status: DeploymentStatus + status_message: str = "" + endpoints: list[Endpoint] = Field(default_factory=list) + exit_code: int | None = None + error_details: dict[str, Any] | None = None + status_history: list[StatusEvent] | None = None + + +class UpdateVolumeStatusRequest(BaseModel): + status: VolumeStatus + status_message: str = "" + error_details: dict[str, Any] | None = None + + +class DeploymentConfigFilter(NemoFilter): + restart_policy: RestartPolicy | None = None + + +class DeploymentFilter(NemoFilter): + deployment_config_name: str | None = None + desired_state: DesiredState | None = None + executor: str | None = None + status: DeploymentStatus | None = None + + +class VolumeFilter(NemoFilter): + status: VolumeStatus | None = None + + +DeploymentConfigPage = NemoListResponse[DeploymentConfig] +DeploymentPage = NemoListResponse[Deployment] +VolumePage = NemoListResponse[Volume] diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/service.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/service.py new file mode 100644 index 0000000000..26ff4f63cc --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/service.py @@ -0,0 +1,92 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Deployments plugin service registration.""" + +from __future__ import annotations + +import logging +from typing import ClassVar + +from nemo_deployments_plugin.backends.registry import ExecutorRegistry, ExecutorSpec +from nemo_deployments_plugin.config import DeploymentsConfig +from nemo_platform import AsyncNeMoPlatform +from nemo_platform_plugin.sdk_provider import get_async_platform_sdk +from nemo_platform_plugin.service import NemoService, RouterSpec + +logger = logging.getLogger(__name__) + + +class DeploymentsService(NemoService): + """HTTP service for deployment configs, deployments, volumes, and controller status.""" + + name: ClassVar[str] = "deployments" + dependencies: ClassVar[list[str]] = ["entities", "auth"] + + def __init__(self) -> None: + self._executor_registry: ExecutorRegistry | None = None + + @property + def executor_registry(self) -> ExecutorRegistry: + if self._executor_registry is None: + self._executor_registry = ExecutorRegistry.empty() + return self._executor_registry + + def get_routers(self) -> list[RouterSpec]: + from nemo_deployments_plugin.api.v1 import ( + deployment_configs, + deployments, + status, + volumes, + ) + + prefix = "/v1/workspaces/{workspace}" + return [ + RouterSpec( + deployment_configs.router, + tag="Deployment Configs", + description="Immutable deployment templates", + prefix=prefix, + ), + RouterSpec( + deployments.router, + tag="Deployments", + description="Deployment lifecycle", + prefix=prefix, + ), + RouterSpec( + volumes.router, + tag="Volumes", + description="Volume lifecycle", + prefix=prefix, + ), + RouterSpec( + status.router, + tag="Deployment Status", + description="Controller-only status projection", + prefix=prefix, + ), + ] + + async def on_startup(self) -> None: + config = DeploymentsConfig.get() + sdk: AsyncNeMoPlatform = get_async_platform_sdk(as_service="deployments", internal=True) + specs = [ExecutorSpec(name=e.name, backend=e.backend, config=e.config) for e in config.executors] + if specs: + self._executor_registry = ExecutorRegistry.from_config( + sdk, + specs, + default_executor=config.default_executor, + ) + else: + self._executor_registry = ExecutorRegistry.empty() + if config.default_executor: + logger.warning( + "default_executor '%s' is configured but no executors are registered.", + config.default_executor, + ) + logger.info("Deployments plugin started with zero registered executors.") + + async def on_shutdown(self) -> None: + if self._executor_registry is not None: + self._executor_registry.shutdown_all() diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/types.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/types.py new file mode 100644 index 0000000000..dbcc67a073 --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/types.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Shared deployment status and endpoint types (no entity imports).""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel + +DeploymentStatus = Literal[ + "PENDING", + "STARTING", + "READY", + "SUCCEEDED", + "FAILED", + "LOST", + "DELETING", +] +VolumeStatus = Literal["PENDING", "BOUND", "RELEASED", "FAILED"] +DesiredState = Literal["READY", "STOPPED"] +RestartPolicy = Literal["Always", "OnFailure", "Never"] +AccessMode = Literal["ReadWriteOnce", "ReadOnlyMany", "ReadWriteMany"] +DriftRecoveryAction = Literal["recreate", "ignore"] + + +class Endpoint(BaseModel): + name: str + url: str + protocol: Literal["http", "https", "grpc", "tcp"] = "http" diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/validation.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/validation.py new file mode 100644 index 0000000000..dd45549820 --- /dev/null +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/validation.py @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Prerequisite graph validation for DeploymentConfig entities.""" + +from __future__ import annotations + +from collections import defaultdict + +from nemo_deployments_plugin.entities import DeploymentConfig, Prerequisite + + +class PrerequisiteCycleError(ValueError): + """Raised when deployment prerequisites contain a cycle.""" + + +def detect_prerequisite_cycle( + *, + config_name: str, + prerequisites: list[str], + existing: dict[str, list[str]], +) -> None: + """Detect cycles in the prerequisite graph within a workspace.""" + graph: dict[str, list[str]] = {name: list(deps) for name, deps in existing.items()} + graph[config_name] = list(prerequisites) + + visited: set[str] = set() + stack: set[str] = set() + + def dfs(node: str) -> None: + if node in stack: + raise PrerequisiteCycleError(f"Prerequisite cycle detected involving deployment config '{node}'.") + if node in visited: + return + visited.add(node) + stack.add(node) + for dep in graph.get(node, []): + dfs(dep) + stack.remove(node) + + for node in graph: + dfs(node) + + +def prerequisite_names(prerequisites: list[Prerequisite]) -> list[str]: + return [prerequisite.deployment_name for prerequisite in prerequisites] + + +def build_existing_prerequisite_map(configs: list[DeploymentConfig]) -> dict[str, list[str]]: + graph: dict[str, list[str]] = defaultdict(list) + for cfg in configs: + graph[cfg.name] = prerequisite_names(cfg.prerequisites) + return dict(graph) diff --git a/plugins/nemo-deployments/tests/unit/helpers.py b/plugins/nemo-deployments/tests/unit/helpers.py new file mode 100644 index 0000000000..989acf5bb3 --- /dev/null +++ b/plugins/nemo-deployments/tests/unit/helpers.py @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from unittest.mock import MagicMock + +from nemo_deployments_plugin.entities import Container, Deployment, DeploymentConfig, Volume +from nemo_platform_plugin.entity_client import NemoPaginationInfo + +NOW = datetime.now(timezone.utc) + + +def make_deployment_config(name: str = "cfg1", workspace: str = "default") -> DeploymentConfig: + cfg = DeploymentConfig( + name=name, + workspace=workspace, + containers=[Container(name="main", image="nginx")], + ) + cfg._id = f"id-{name}" + cfg._created_at = NOW + return cfg + + +def make_deployment(name: str = "dep1", workspace: str = "default") -> Deployment: + dep = Deployment(name=name, workspace=workspace, deployment_config_name="cfg1", status="PENDING") + dep._id = f"id-{name}" + dep._created_at = NOW + return dep + + +def make_volume(name: str = "vol1", workspace: str = "default") -> Volume: + vol = Volume(name=name, workspace=workspace) + vol._id = f"id-{name}" + vol._created_at = NOW + return vol + + +def list_response(items: list[Any]) -> MagicMock: + resp = MagicMock() + resp.data = items + resp.pagination = NemoPaginationInfo( + page=1, + page_size=20, + current_page_size=len(items), + total_pages=1, + total_results=len(items), + ) + return resp diff --git a/plugins/nemo-deployments/tests/unit/test_api_deployment_configs.py b/plugins/nemo-deployments/tests/unit/test_api_deployment_configs.py new file mode 100644 index 0000000000..73736da889 --- /dev/null +++ b/plugins/nemo-deployments/tests/unit/test_api_deployment_configs.py @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from helpers import list_response, make_deployment_config +from nemo_deployments_plugin.api.v1 import deployment_configs as configs_module +from nemo_deployments_plugin.api.v1.dependencies import get_entity_client +from nemo_deployments_plugin.entities import Prerequisite +from nemo_platform_plugin.entity_client import NemoEntityConflictError, NemoEntityNotFoundError + + +@pytest.fixture +def mock_entity_client() -> AsyncMock: + return AsyncMock() + + +@pytest.fixture +def client(mock_entity_client: AsyncMock) -> TestClient: + app = FastAPI() + app.include_router( + configs_module.router, + prefix="/apis/deployments/v1/workspaces/{workspace}", + ) + app.dependency_overrides[get_entity_client] = lambda: mock_entity_client + return TestClient(app, raise_server_exceptions=False) + + +def test_create_deployment_config_201(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.list.return_value = list_response([]) + mock_entity_client.create.return_value = make_deployment_config("cfg1") + resp = client.post( + "/apis/deployments/v1/workspaces/default/deployment-configs", + json={"name": "cfg1", "containers": [{"name": "main", "image": "nginx"}]}, + ) + assert resp.status_code == 201 + assert resp.json()["name"] == "cfg1" + + +def test_create_deployment_config_cycle_400(client: TestClient, mock_entity_client: AsyncMock) -> None: + a = make_deployment_config("a") + b = make_deployment_config("b") + b.prerequisites = [Prerequisite(deployment_name="a")] + mock_entity_client.list.return_value = list_response([a, b]) + resp = client.post( + "/apis/deployments/v1/workspaces/default/deployment-configs", + json={"name": "a", "prerequisites": [{"deployment_name": "b"}]}, + ) + assert resp.status_code == 400 + assert "cycle" in resp.json()["detail"].lower() + + +def test_get_deployment_config_404(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.get.side_effect = NemoEntityNotFoundError("missing") + resp = client.get("/apis/deployments/v1/workspaces/default/deployment-configs/missing") + assert resp.status_code == 404 + + +def test_delete_deployment_config_204(client: TestClient) -> None: + resp = client.delete("/apis/deployments/v1/workspaces/default/deployment-configs/cfg1") + assert resp.status_code == 204 + + +def test_create_deployment_config_409(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.list.return_value = list_response([]) + mock_entity_client.create.side_effect = NemoEntityConflictError("exists") + resp = client.post( + "/apis/deployments/v1/workspaces/default/deployment-configs", + json={"name": "cfg1"}, + ) + assert resp.status_code == 409 diff --git a/plugins/nemo-deployments/tests/unit/test_api_deployments.py b/plugins/nemo-deployments/tests/unit/test_api_deployments.py new file mode 100644 index 0000000000..b3b7deb8a9 --- /dev/null +++ b/plugins/nemo-deployments/tests/unit/test_api_deployments.py @@ -0,0 +1,88 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from helpers import list_response, make_deployment, make_deployment_config +from nemo_deployments_plugin.api.v1 import deployments as deployments_module +from nemo_deployments_plugin.api.v1.dependencies import get_entity_client +from nemo_platform_plugin.entity_client import NemoEntityConflictError, NemoEntityNotFoundError + + +@pytest.fixture +def mock_entity_client() -> AsyncMock: + return AsyncMock() + + +@pytest.fixture +def client(mock_entity_client: AsyncMock) -> TestClient: + app = FastAPI() + app.include_router( + deployments_module.router, + prefix="/apis/deployments/v1/workspaces/{workspace}", + ) + app.dependency_overrides[get_entity_client] = lambda: mock_entity_client + return TestClient(app, raise_server_exceptions=False) + + +def test_create_deployment_validates_config(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.get.side_effect = NemoEntityNotFoundError("missing") + resp = client.post( + "/apis/deployments/v1/workspaces/default/deployments", + json={"name": "dep1", "deployment_config_name": "missing"}, + ) + assert resp.status_code == 404 + + +def test_create_deployment_201(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.get.return_value = make_deployment_config() + mock_entity_client.create.return_value = make_deployment() + resp = client.post( + "/apis/deployments/v1/workspaces/default/deployments", + json={"name": "dep1", "deployment_config_name": "cfg1"}, + ) + assert resp.status_code == 201 + assert resp.json()["status"] == "PENDING" + + +def test_list_deployments_status_in(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.list.return_value = list_response([make_deployment()]) + resp = client.get( + "/apis/deployments/v1/workspaces/default/deployments", + params={"status_in": "pending,starting"}, + ) + assert resp.status_code == 200 + assert len(resp.json()["data"]) == 1 + call_kwargs = mock_entity_client.list.await_args.kwargs + assert call_kwargs["filter_operation"].operator.value == "$in" + assert call_kwargs["filter_operation"].field == "status" + + +def test_list_deployments_invalid_status_in_400(client: TestClient) -> None: + resp = client.get( + "/apis/deployments/v1/workspaces/default/deployments", + params={"status_in": "banana"}, + ) + assert resp.status_code == 400 + + +def test_delete_deployment_marks_deleting(client: TestClient, mock_entity_client: AsyncMock) -> None: + deployment = make_deployment() + mock_entity_client.get.return_value = deployment + resp = client.delete("/apis/deployments/v1/workspaces/default/deployments/dep1") + assert resp.status_code == 204 + mock_entity_client.update.assert_awaited_once() + updated = mock_entity_client.update.await_args.args[0] + assert updated.status == "DELETING" + + +def test_delete_deployment_conflict_409(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.get.return_value = make_deployment() + mock_entity_client.update.side_effect = NemoEntityConflictError("conflict") + resp = client.delete("/apis/deployments/v1/workspaces/default/deployments/dep1") + assert resp.status_code == 409 diff --git a/plugins/nemo-deployments/tests/unit/test_api_status.py b/plugins/nemo-deployments/tests/unit/test_api_status.py new file mode 100644 index 0000000000..1889bc74a7 --- /dev/null +++ b/plugins/nemo-deployments/tests/unit/test_api_status.py @@ -0,0 +1,86 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from helpers import make_deployment, make_volume +from nemo_deployments_plugin.api.v1 import status as status_module +from nemo_deployments_plugin.api.v1.dependencies import get_entity_client +from nemo_platform_plugin.entity_client import NemoEntityNotFoundError + +_SERVICE_HEADERS = {"X-NMP-Principal-Id": "service:deployments"} +_USER_HEADERS = {"X-NMP-Principal-Id": "user@example.com"} + + +@pytest.fixture +def mock_entity_client() -> AsyncMock: + return AsyncMock() + + +@pytest.fixture +def client(mock_entity_client: AsyncMock) -> TestClient: + app = FastAPI() + app.include_router( + status_module.router, + prefix="/apis/deployments/v1/workspaces/{workspace}", + ) + app.dependency_overrides[get_entity_client] = lambda: mock_entity_client + return TestClient(app, raise_server_exceptions=False) + + +def test_status_put_rejects_user(client: TestClient) -> None: + resp = client.put( + "/apis/deployments/v1/workspaces/default/deployments/dep1/status", + json={"status": "READY"}, + headers=_USER_HEADERS, + ) + assert resp.status_code == 403 + + +def test_status_put_ignores_on_behalf_of_for_auth(client: TestClient) -> None: + resp = client.put( + "/apis/deployments/v1/workspaces/default/deployments/dep1/status", + json={"status": "READY"}, + headers={ + "X-NMP-Principal-Id": "user@example.com", + "X-NMP-Principal-On-Behalf-Of": "service:deployments", + }, + ) + assert resp.status_code == 403 + + +def test_status_put_accepts_service_principal(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.get.return_value = make_deployment() + mock_entity_client.update.return_value = make_deployment() + resp = client.put( + "/apis/deployments/v1/workspaces/default/deployments/dep1/status", + json={"status": "READY", "status_message": "up"}, + headers=_SERVICE_HEADERS, + ) + assert resp.status_code == 200 + + +def test_volume_status_put_accepts_service_principal(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.get.return_value = make_volume() + mock_entity_client.update.return_value = make_volume() + resp = client.put( + "/apis/deployments/v1/workspaces/default/volumes/vol1/status", + json={"status": "BOUND"}, + headers=_SERVICE_HEADERS, + ) + assert resp.status_code == 200 + + +def test_status_put_404(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.get.side_effect = NemoEntityNotFoundError("missing") + resp = client.put( + "/apis/deployments/v1/workspaces/default/deployments/missing/status", + json={"status": "READY"}, + headers=_SERVICE_HEADERS, + ) + assert resp.status_code == 404 diff --git a/plugins/nemo-deployments/tests/unit/test_api_volumes.py b/plugins/nemo-deployments/tests/unit/test_api_volumes.py new file mode 100644 index 0000000000..c7fc71bd1b --- /dev/null +++ b/plugins/nemo-deployments/tests/unit/test_api_volumes.py @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from helpers import list_response, make_volume +from nemo_deployments_plugin.api.v1 import volumes as volumes_module +from nemo_deployments_plugin.api.v1.dependencies import get_entity_client + + +@pytest.fixture +def mock_entity_client() -> AsyncMock: + return AsyncMock() + + +@pytest.fixture +def client(mock_entity_client: AsyncMock) -> TestClient: + app = FastAPI() + app.include_router( + volumes_module.router, + prefix="/apis/deployments/v1/workspaces/{workspace}", + ) + app.dependency_overrides[get_entity_client] = lambda: mock_entity_client + return TestClient(app, raise_server_exceptions=False) + + +def test_create_volume_201(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.create.return_value = make_volume() + resp = client.post( + "/apis/deployments/v1/workspaces/default/volumes", + json={"name": "vol1", "size": "5Gi"}, + ) + assert resp.status_code == 201 + assert resp.json()["status"] == "PENDING" + created = mock_entity_client.create.await_args.args[0] + assert created.name == "vol1" + assert created.size == "5Gi" + assert created.workspace == "default" + + +def test_list_volumes_200(client: TestClient, mock_entity_client: AsyncMock) -> None: + mock_entity_client.list.return_value = list_response([make_volume()]) + resp = client.get("/apis/deployments/v1/workspaces/default/volumes") + assert resp.status_code == 200 + assert len(resp.json()["data"]) == 1 diff --git a/plugins/nemo-deployments/tests/unit/test_config.py b/plugins/nemo-deployments/tests/unit/test_config.py new file mode 100644 index 0000000000..9f25478d37 --- /dev/null +++ b/plugins/nemo-deployments/tests/unit/test_config.py @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import pytest +from nemo_deployments_plugin.config import DeploymentsConfig + + +def test_config_rejects_inverted_port_range() -> None: + with pytest.raises(ValueError, match="port_range_end"): + DeploymentsConfig.model_validate({"port_range_start": 9100, "port_range_end": 9000}) diff --git a/plugins/nemo-deployments/tests/unit/test_entities.py b/plugins/nemo-deployments/tests/unit/test_entities.py new file mode 100644 index 0000000000..f9e560dbf1 --- /dev/null +++ b/plugins/nemo-deployments/tests/unit/test_entities.py @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import pytest +from nemo_deployments_plugin.entities import Container, Deployment, DeploymentConfig, Volume +from nemo_deployments_plugin.validation import PrerequisiteCycleError, detect_prerequisite_cycle +from pydantic import ValidationError + + +def test_deployment_defaults_to_pending() -> None: + dep = Deployment(name="d1", workspace="default", deployment_config_name="cfg") + assert dep.status == "PENDING" + assert dep.desired_state == "READY" + + +def test_deployment_config_requires_containers_shape() -> None: + cfg = DeploymentConfig( + name="cfg", + workspace="default", + containers=[Container(name="main", image="nginx:latest")], + ) + assert cfg.containers[0].image == "nginx:latest" + + +def test_volume_default_status_pending() -> None: + vol = Volume(name="v1", workspace="default") + assert vol.status == "PENDING" + assert vol.size == "1Gi" + + +def test_prerequisite_cycle_detected() -> None: + with pytest.raises(PrerequisiteCycleError): + detect_prerequisite_cycle( + config_name="c", + prerequisites=["a"], + existing={"a": ["b"], "b": ["c"]}, + ) + + +def test_invalid_deployment_status_rejected() -> None: + with pytest.raises(ValidationError): + Deployment.model_validate( + { + "name": "d1", + "workspace": "default", + "deployment_config_name": "cfg", + "status": "not-a-status", + } + ) diff --git a/plugins/nemo-deployments/tests/unit/test_prerequisite_validation.py b/plugins/nemo-deployments/tests/unit/test_prerequisite_validation.py new file mode 100644 index 0000000000..5954b6854f --- /dev/null +++ b/plugins/nemo-deployments/tests/unit/test_prerequisite_validation.py @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import pytest +from nemo_deployments_plugin.validation import PrerequisiteCycleError, detect_prerequisite_cycle + + +def test_linear_prerequisites_ok() -> None: + detect_prerequisite_cycle( + config_name="c", + prerequisites=["b"], + existing={"a": [], "b": ["a"]}, + ) + + +def test_self_cycle_rejected() -> None: + with pytest.raises(PrerequisiteCycleError, match="cycle"): + detect_prerequisite_cycle( + config_name="a", + prerequisites=["a"], + existing={}, + ) + + +def test_three_node_cycle_rejected() -> None: + with pytest.raises(PrerequisiteCycleError): + detect_prerequisite_cycle( + config_name="a", + prerequisites=["c"], + existing={"b": ["a"], "c": ["b"]}, + ) diff --git a/plugins/nemo-deployments/tests/unit/test_registry.py b/plugins/nemo-deployments/tests/unit/test_registry.py new file mode 100644 index 0000000000..8bd6fe2515 --- /dev/null +++ b/plugins/nemo-deployments/tests/unit/test_registry.py @@ -0,0 +1,136 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from typing import Any + +import pytest +from nemo_deployments_plugin.backends.base import BackendStatusUpdate, DeploymentBackend, LogResult, VolumeStatusUpdate +from nemo_deployments_plugin.backends.registry import ( + ExecutorNotFoundError, + ExecutorRegistry, + ExecutorSpec, + UnknownBackendTypeError, +) +from nemo_platform import AsyncNeMoPlatform + + +class _StubBackend(DeploymentBackend): + def shutdown(self) -> None: + pass + + async def create_deployment(self, **kwargs: Any) -> BackendStatusUpdate: + return BackendStatusUpdate(status="PENDING") + + async def read_status(self, **kwargs: Any) -> BackendStatusUpdate: + return BackendStatusUpdate(status="READY") + + async def delete_deployment(self, workspace: str, name: str) -> BackendStatusUpdate: + return BackendStatusUpdate(status="DELETING") + + async def list_managed_deployment_names(self) -> list[str]: + return [] + + async def get_logs(self, **kwargs: Any) -> LogResult: + return LogResult(lines=[]) + + async def create_volume(self, **kwargs: Any) -> VolumeStatusUpdate: + return VolumeStatusUpdate(status="PENDING") + + async def read_volume_status(self, **kwargs: Any) -> VolumeStatusUpdate: + return VolumeStatusUpdate(status="BOUND") + + async def delete_volume(self, workspace: str, name: str) -> VolumeStatusUpdate: + return VolumeStatusUpdate(status="RELEASED") + + +@pytest.fixture +def backend_classes() -> dict[str, type[DeploymentBackend]]: + return {"docker": _StubBackend, "k8s": _StubBackend} + + +def test_empty_registry_starts(backend_classes: dict[str, type[DeploymentBackend]]) -> None: + registry = ExecutorRegistry.empty() + assert registry.registered_names() == [] + with pytest.raises(ExecutorNotFoundError): + registry.resolve() + + +def test_resolve_by_name(backend_classes: dict[str, type[DeploymentBackend]]) -> None: + sdk = AsyncNeMoPlatform(base_url="http://localhost:8080") + registry = ExecutorRegistry.from_config( + sdk, + [ + ExecutorSpec(name="local-docker", backend="docker", config={"port_range_start": 9000}), + ExecutorSpec(name="cluster-a", backend="k8s", config={}), + ], + default_executor="local-docker", + backend_classes=backend_classes, + ) + assert registry.resolve("cluster-a") is not None + assert registry.resolve() is registry.resolve("local-docker") + + +def test_missing_executor_raises(backend_classes: dict[str, type[DeploymentBackend]]) -> None: + sdk = AsyncNeMoPlatform(base_url="http://localhost:8080") + registry = ExecutorRegistry.from_config( + sdk, + [ExecutorSpec(name="a", backend="docker", config={})], + backend_classes=backend_classes, + ) + with pytest.raises(ExecutorNotFoundError): + registry.resolve("missing") + + +def test_unknown_backend_type_raises() -> None: + sdk = AsyncNeMoPlatform(base_url="http://localhost:8080") + with pytest.raises(UnknownBackendTypeError): + ExecutorRegistry.from_config( + sdk, + [ExecutorSpec(name="a", backend="unknown", config={})], + backend_classes={"docker": _StubBackend}, + ) + + +class _FailingBackend(_StubBackend): + def init(self) -> None: + raise RuntimeError("init failed") + + +def test_registry_rolls_back_on_partial_init(backend_classes: dict[str, type[DeploymentBackend]]) -> None: + sdk = AsyncNeMoPlatform(base_url="http://localhost:8080") + classes = {**backend_classes, "fail": _FailingBackend} + shutdown_calls: list[str] = [] + + class _TrackingStub(_StubBackend): + def shutdown(self) -> None: + shutdown_calls.append("shutdown") + + classes["docker"] = _TrackingStub + with pytest.raises(RuntimeError, match="init failed"): + ExecutorRegistry.from_config( + sdk, + [ + ExecutorSpec(name="ok", backend="docker", config={}), + ExecutorSpec(name="bad", backend="fail", config={}), + ], + backend_classes=classes, + ) + assert shutdown_calls == ["shutdown"] + + +def test_multiple_docker_executors_distinct_config(backend_classes: dict[str, type[DeploymentBackend]]) -> None: + sdk = AsyncNeMoPlatform(base_url="http://localhost:8080") + registry = ExecutorRegistry.from_config( + sdk, + [ + ExecutorSpec(name="docker-a", backend="docker", config={"port_range_start": 9000}), + ExecutorSpec(name="docker-b", backend="docker", config={"port_range_start": 9100}), + ], + backend_classes=backend_classes, + ) + a = registry.resolve("docker-a") + b = registry.resolve("docker-b") + assert a._config["port_range_start"] == 9000 + assert b._config["port_range_start"] == 9100 diff --git a/plugins/nemo-deployments/tests/unit/test_service_startup.py b/plugins/nemo-deployments/tests/unit/test_service_startup.py new file mode 100644 index 0000000000..13318daa4d --- /dev/null +++ b/plugins/nemo-deployments/tests/unit/test_service_startup.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from fastapi.routing import APIRoute +from nemo_deployments_plugin.service import DeploymentsService + + +def _mounted_paths() -> set[str]: + service = DeploymentsService() + paths: set[str] = set() + for spec in service.get_routers(): + for route in spec.router.routes: + if isinstance(route, APIRoute): + paths.add(f"/apis/deployments{spec.prefix}{route.path}") + return paths + + +def test_service_mounts_core_routes() -> None: + paths = _mounted_paths() + assert "/apis/deployments/v1/workspaces/{workspace}/deployment-configs" in paths + assert "/apis/deployments/v1/workspaces/{workspace}/deployments" in paths + assert "/apis/deployments/v1/workspaces/{workspace}/volumes" in paths + assert "/apis/deployments/v1/workspaces/{workspace}/deployments/{name}/status" in paths + assert "/apis/deployments/v1/workspaces/{workspace}/volumes/{name}/status" in paths + + +def test_service_name_matches_entry_point() -> None: + assert DeploymentsService.name == "deployments" diff --git a/pyproject.toml b/pyproject.toml index 3702b119aa..c7eb964222 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -174,6 +174,7 @@ enabled-plugins = [ "nemo-safe-synthesizer-plugin", "nemo-switchyard", "nemo-agents-plugin", + "nemo-deployments-plugin", "nemo-customizer-plugin", "nemo-automodel-plugin", "nemo-unsloth-plugin", @@ -368,6 +369,7 @@ nemo-auditor-plugin = { workspace = true } nemo-safe-synthesizer-plugin = { workspace = true } nemo-switchyard = { workspace = true } nemo-agents-plugin = { workspace = true } +nemo-deployments-plugin = { workspace = true } nemo-agents-example-calculator = { workspace = true } nemo-customizer-plugin = { workspace = true } nemo-automodel-plugin = { workspace = true } @@ -420,6 +422,7 @@ members = [ "plugins/nemo-safe-synthesizer", "plugins/nemo-switchyard", "plugins/nemo-agents", + "plugins/nemo-deployments", "plugins/nemo-agents/examples/calculator-agent", "plugins/nemo-customizer", "plugins/nemo-automodel", diff --git a/pytest.ini b/pytest.ini index 2dd3a99528..2e0c0a9b38 100644 --- a/pytest.ini +++ b/pytest.ini @@ -9,6 +9,8 @@ python_functions = test_* pythonpath = . plugins/example-plugin/src + plugins/nemo-deployments/src + plugins/nemo-deployments/tests/unit plugins/nemo-safe-synthesizer/src # Test discovery paths - packages and stable services diff --git a/uv.lock b/uv.lock index ee0c8038c0..26ecc7aeb2 100644 --- a/uv.lock +++ b/uv.lock @@ -31,6 +31,7 @@ members = [ "nemo-automodel-plugin", "nemo-customizer-plugin", "nemo-data-designer-plugin", + "nemo-deployments-plugin", "nemo-evaluator-plugin", "nemo-evaluator-sdk", "nemo-guardrails-plugin", @@ -4349,6 +4350,41 @@ requires-dist = [ ] provides-extras = ["test", "data-designer-nemo", "nemo-platform-plugin"] +[[package]] +name = "nemo-deployments-plugin" +version = "0.1.0" +source = { editable = "plugins/nemo-deployments" } +dependencies = [ + { name = "fastapi", 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-platform", 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-platform-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 = "pydantic", extra = ["email"], 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')" }, +] + +[package.dev-dependencies] +dev = [ + { name = "fastapi", 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 = "httpx", 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 = "pytest", 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 = "pytest-asyncio", 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')" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.115" }, + { name = "nemo-platform", editable = "packages/nemo_platform" }, + { name = "nemo-platform-plugin", editable = "packages/nemo_platform_plugin" }, + { name = "pydantic", specifier = ">=2.10.6" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "fastapi", specifier = ">=0.115" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "pytest", specifier = ">=8.3.4" }, + { name = "pytest-asyncio", specifier = ">=0.25.3" }, +] + [[package]] name = "nemo-evaluator-plugin" version = "0.1.0" @@ -6206,6 +6242,7 @@ core-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", 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-guardrails-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-platform", extra = ["services"], 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')" }, @@ -6302,6 +6339,7 @@ enabled-plugins = [ { 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", 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-guardrails-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-safe-synthesizer-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')" }, @@ -6315,6 +6353,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", 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-guardrails-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-platform", extra = ["services"], 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')" }, @@ -6406,6 +6445,7 @@ core-services = [ { name = "nemo-automodel-plugin", editable = "plugins/nemo-automodel" }, { name = "nemo-customizer-plugin", editable = "plugins/nemo-customizer" }, { name = "nemo-data-designer-plugin", editable = "plugins/nemo-data-designer" }, + { name = "nemo-deployments-plugin", editable = "plugins/nemo-deployments" }, { name = "nemo-evaluator-plugin", editable = "plugins/nemo-evaluator" }, { name = "nemo-guardrails-plugin", editable = "plugins/nemo-guardrails" }, { name = "nemo-platform", editable = "packages/nemo_platform" }, @@ -6505,6 +6545,7 @@ enabled-plugins = [ { name = "nemo-automodel-plugin", editable = "plugins/nemo-automodel" }, { name = "nemo-customizer-plugin", editable = "plugins/nemo-customizer" }, { name = "nemo-data-designer-plugin", editable = "plugins/nemo-data-designer" }, + { name = "nemo-deployments-plugin", editable = "plugins/nemo-deployments" }, { name = "nemo-evaluator-plugin", editable = "plugins/nemo-evaluator" }, { name = "nemo-guardrails-plugin", editable = "plugins/nemo-guardrails" }, { name = "nemo-safe-synthesizer-plugin", editable = "plugins/nemo-safe-synthesizer" }, @@ -6518,6 +6559,7 @@ functional-services = [ { name = "nemo-automodel-plugin", editable = "plugins/nemo-automodel" }, { name = "nemo-customizer-plugin", editable = "plugins/nemo-customizer" }, { name = "nemo-data-designer-plugin", editable = "plugins/nemo-data-designer" }, + { name = "nemo-deployments-plugin", editable = "plugins/nemo-deployments" }, { name = "nemo-evaluator-plugin", editable = "plugins/nemo-evaluator" }, { name = "nemo-guardrails-plugin", editable = "plugins/nemo-guardrails" }, { name = "nemo-platform", editable = "packages/nemo_platform" },