diff --git a/plugins/nemo-deployments/README.md b/plugins/nemo-deployments/README.md index 3ea8a44eca..d3afb0b702 100644 --- a/plugins/nemo-deployments/README.md +++ b/plugins/nemo-deployments/README.md @@ -23,3 +23,26 @@ Prerequisites are declared on each `Deployment` and reference other deployment names in the same workspace (bare name or `workspace/name`). The controller loads terminal prerequisite deployments from the entity store when they are not in the active non-terminal list. + +## Docker executors + +Host port allocation for published container ports is configured per named docker +executor (not on `DeploymentConfig.backend_config.docker`). Set the inclusive +`port_range_start` / `port_range_end` bounds on the executor `config` block in +platform YAML. The allocator scans every host port from `port_range_start` +through `port_range_end`, including both endpoints (for example, 9000–9100 +allows 101 ports): + +```yaml +deployments: + executors: + - name: local-docker + backend: docker + config: + port_range_start: 9000 + port_range_end: 9100 # inclusive + default_executor: local-docker +``` + +Entity-level `backend_config.docker` accepts only deployment-specific overrides such +as `network`. diff --git a/plugins/nemo-deployments/openapi/openapi.yaml b/plugins/nemo-deployments/openapi/openapi.yaml index 6aaea31ae7..c010d1b10a 100644 --- a/plugins/nemo-deployments/openapi/openapi.yaml +++ b/plugins/nemo-deployments/openapi/openapi.yaml @@ -991,14 +991,6 @@ components: description: Immutable PodSpec-shaped deployment template. DockerDeploymentConfig: properties: - port_range_start: - type: integer - title: Port Range Start - default: 9000 - port_range_end: - type: integer - title: Port Range End - default: 9100 network: title: Network type: string diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/backend.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/backend.py index be7ca9f61c..ed91565423 100644 --- a/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/backend.py +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/backend.py @@ -163,7 +163,8 @@ async def create_deployment( for port_spec in container_spec.ports: host_port = await find_available_port( self._client, - docker_cfg, + self._executor_config.port_range_start, + self._executor_config.port_range_end, exclude_ports=set(host_ports.values()), ) if host_port is None: diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/config.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/config.py index cf866a9ae3..c066b07e59 100644 --- a/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/config.py +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/config.py @@ -5,7 +5,7 @@ from __future__ import annotations -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator class DockerExecutorConfig(BaseModel): @@ -18,3 +18,21 @@ class DockerExecutorConfig(BaseModel): description="Docker client timeout in seconds for pull/create/status operations (default: 10 minutes).", ) pull_images: bool = Field(default=True, description="Pull container images before run when missing locally.") + port_range_start: int = Field( + default=9000, + ge=1, + le=65535, + description="First host port to consider when publishing container ports for this executor.", + ) + port_range_end: int = Field( + default=9100, + ge=1, + le=65535, + description="Last host port (inclusive) to consider when publishing container ports for this executor.", + ) + + @model_validator(mode="after") + def _validate_port_range(self) -> DockerExecutorConfig: + if self.port_range_start > self.port_range_end: + raise ValueError("port_range_start must not exceed port_range_end") + return self diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/ports.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/ports.py index 6e448787a9..cc9239b7f8 100644 --- a/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/ports.py +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/ports.py @@ -12,7 +12,6 @@ from typing import TYPE_CHECKING from nemo_deployments_plugin.backends.docker.labels import managed_by_filter -from nemo_deployments_plugin.entities import DockerDeploymentConfig import docker @@ -57,7 +56,8 @@ def collect_used_host_ports(containers: list[DockerContainer]) -> set[int]: async def find_available_port( client: docker.DockerClient, - docker_cfg: DockerDeploymentConfig, + port_range_start: int, + port_range_end: int, *, exclude_ports: set[int] | None = None, ) -> int | None: @@ -74,13 +74,13 @@ async def find_available_port( used_ports = collect_used_host_ports(containers) if exclude_ports: used_ports = used_ports | exclude_ports - for port in range(docker_cfg.port_range_start, docker_cfg.port_range_end + 1): + for port in range(port_range_start, port_range_end + 1): if port not in used_ports and is_port_free(port): return port logger.error( "No available ports in range %s-%s", - docker_cfg.port_range_start, - docker_cfg.port_range_end, + port_range_start, + port_range_end, ) return None diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/entities.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/entities.py index 71099f0767..c3b42d3fae 100644 --- a/plugins/nemo-deployments/src/nemo_deployments_plugin/entities.py +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/entities.py @@ -140,8 +140,6 @@ class Affinity(BaseModel): class DockerDeploymentConfig(BaseModel): - port_range_start: int = 9000 - port_range_end: int = 9100 network: str | None = None diff --git a/plugins/nemo-deployments/tests/integration/backends/docker/test_docker_backend.py b/plugins/nemo-deployments/tests/integration/backends/docker/test_docker_backend.py index f7be34295f..e0ca1154f6 100644 --- a/plugins/nemo-deployments/tests/integration/backends/docker/test_docker_backend.py +++ b/plugins/nemo-deployments/tests/integration/backends/docker/test_docker_backend.py @@ -19,9 +19,7 @@ Container, ContainerPort, Deployment, - DeploymentBackendConfig, DeploymentConfig, - DockerDeploymentConfig, ) import docker @@ -67,9 +65,6 @@ def _always_http_config() -> DeploymentConfig: ports=[ContainerPort(containerPort=80, protocol="TCP", name="http")], ) ], - backend_config=DeploymentBackendConfig( - docker=DockerDeploymentConfig(port_range_start=9050, port_range_end=9060) - ), ) diff --git a/plugins/nemo-deployments/tests/integration/test_reconcile_docker.py b/plugins/nemo-deployments/tests/integration/test_reconcile_docker.py index be6c88eae5..bc45edb442 100644 --- a/plugins/nemo-deployments/tests/integration/test_reconcile_docker.py +++ b/plugins/nemo-deployments/tests/integration/test_reconcile_docker.py @@ -22,9 +22,7 @@ from nemo_deployments_plugin.entities import ( Container, Deployment, - DeploymentBackendConfig, DeploymentConfig, - DockerDeploymentConfig, Prerequisite, Volume, VolumeMount, @@ -111,9 +109,6 @@ async def test_puller_server_prerequisite_chain(docker_registry: ExecutorRegistr ) ], volumeMounts=[VolumeMount(name="weights", mountPath="/data")], - backend_config=DeploymentBackendConfig( - docker=DockerDeploymentConfig(port_range_start=9070, port_range_end=9080) - ), ) config_cache = { diff --git a/plugins/nemo-deployments/tests/unit/backends/docker/test_backend_mocked.py b/plugins/nemo-deployments/tests/unit/backends/docker/test_backend_mocked.py index a92306b593..2fba410e60 100644 --- a/plugins/nemo-deployments/tests/unit/backends/docker/test_backend_mocked.py +++ b/plugins/nemo-deployments/tests/unit/backends/docker/test_backend_mocked.py @@ -36,7 +36,7 @@ async def test_create_deployment_starts_container( name="srv", config_name="cfg1", labels={"managed-by": MANAGED_BY_LABEL}, - backend_config={"docker": {"port_range_start": 9000, "port_range_end": 9100}}, + backend_config={}, ) assert update.status == "STARTING" diff --git a/plugins/nemo-deployments/tests/unit/backends/docker/test_executor_config.py b/plugins/nemo-deployments/tests/unit/backends/docker/test_executor_config.py new file mode 100644 index 0000000000..53268dd8a1 --- /dev/null +++ b/plugins/nemo-deployments/tests/unit/backends/docker/test_executor_config.py @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import pytest +from nemo_deployments_plugin.backends.docker.config import DockerExecutorConfig +from pydantic import ValidationError + + +def test_docker_executor_config_defaults() -> None: + cfg = DockerExecutorConfig() + assert cfg.port_range_start == 9000 + assert cfg.port_range_end == 9100 + + +def test_docker_executor_config_rejects_inverted_port_range() -> None: + with pytest.raises(ValidationError, match="port_range_start must not exceed port_range_end"): + DockerExecutorConfig(port_range_start=9200, port_range_end=9100) diff --git a/plugins/nemo-deployments/tests/unit/backends/docker/test_ports.py b/plugins/nemo-deployments/tests/unit/backends/docker/test_ports.py index 7b11700a98..fdf72f26cb 100644 --- a/plugins/nemo-deployments/tests/unit/backends/docker/test_ports.py +++ b/plugins/nemo-deployments/tests/unit/backends/docker/test_ports.py @@ -9,7 +9,6 @@ import pytest from nemo_deployments_plugin.backends.docker.ports import collect_used_host_ports, is_port_free -from nemo_deployments_plugin.entities import DockerDeploymentConfig def test_collect_used_host_ports() -> None: @@ -78,8 +77,7 @@ async def test_find_available_port_skips_used(mock_docker_client: MagicMock, mon mock_docker_client.containers.list.return_value = [used] monkeypatch.setattr(ports_mod, "is_port_free", lambda port: port != 9001) - cfg = DockerDeploymentConfig(port_range_start=9000, port_range_end=9002) - port = await find_available_port(mock_docker_client, cfg) + port = await find_available_port(mock_docker_client, 9000, 9002) assert port == 9002 @@ -88,11 +86,10 @@ async def test_find_available_port_excludes_pending_assignments(mock_docker_clie from nemo_deployments_plugin.backends.docker.ports import find_available_port mock_docker_client.containers.list.return_value = [] - cfg = DockerDeploymentConfig(port_range_start=9000, port_range_end=9002) - first = await find_available_port(mock_docker_client, cfg) + first = await find_available_port(mock_docker_client, 9000, 9002) assert first == 9000 - second = await find_available_port(mock_docker_client, cfg, exclude_ports={first}) + second = await find_available_port(mock_docker_client, 9000, 9002, exclude_ports={first}) assert first == 9000 assert second == 9001 diff --git a/plugins/nemo-deployments/tests/unit/test_registry.py b/plugins/nemo-deployments/tests/unit/test_registry.py index 8bd6fe2515..2dd66dd285 100644 --- a/plugins/nemo-deployments/tests/unit/test_registry.py +++ b/plugins/nemo-deployments/tests/unit/test_registry.py @@ -3,16 +3,22 @@ from __future__ import annotations +from collections.abc import Iterator +from contextlib import contextmanager from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch import pytest +from docker.errors import NotFound from nemo_deployments_plugin.backends.base import BackendStatusUpdate, DeploymentBackend, LogResult, VolumeStatusUpdate +from nemo_deployments_plugin.backends.docker.backend import DockerDeploymentBackend from nemo_deployments_plugin.backends.registry import ( ExecutorNotFoundError, ExecutorRegistry, ExecutorSpec, UnknownBackendTypeError, ) +from nemo_deployments_plugin.entities import Container, ContainerPort, DeploymentConfig from nemo_platform import AsyncNeMoPlatform @@ -50,6 +56,23 @@ def backend_classes() -> dict[str, type[DeploymentBackend]]: return {"docker": _StubBackend, "k8s": _StubBackend} +@contextmanager +def _patched_docker_init( + *, + mock_docker_client: MagicMock | None = None, + mock_entities: AsyncMock | None = None, +) -> Iterator[MagicMock]: + client = mock_docker_client or MagicMock() + entities = mock_entities or AsyncMock() + with ( + patch("nemo_deployments_plugin.backends.docker.backend.AsyncEntitiesResource"), + patch("nemo_deployments_plugin.backends.docker.backend.NemoEntitiesClient", return_value=entities), + patch("nemo_deployments_plugin.backends.docker.backend.get_shared_gpu_pool", return_value=None), + patch("docker.from_env", return_value=client), + ): + yield client + + def test_empty_registry_starts(backend_classes: dict[str, type[DeploymentBackend]]) -> None: registry = ExecutorRegistry.empty() assert registry.registered_names() == [] @@ -120,17 +143,75 @@ def shutdown(self) -> None: assert shutdown_calls == ["shutdown"] -def test_multiple_docker_executors_distinct_config(backend_classes: dict[str, type[DeploymentBackend]]) -> None: +def test_multiple_docker_executors_distinct_config() -> 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, - ) + with _patched_docker_init(): + 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}), + ], + ) a = registry.resolve("docker-a") b = registry.resolve("docker-b") - assert a._config["port_range_start"] == 9000 - assert b._config["port_range_start"] == 9100 + assert isinstance(a, DockerDeploymentBackend) + assert isinstance(b, DockerDeploymentBackend) + assert a._executor_config.port_range_start == 9000 + assert b._executor_config.port_range_start == 9100 + + +@pytest.mark.asyncio +async def test_executor_port_range_used_for_allocation() -> None: + sdk = AsyncNeMoPlatform(base_url="http://localhost:8080") + mock_entities = AsyncMock() + mock_docker_client = MagicMock() + mock_docker_client.containers.get.side_effect = NotFound("missing") + + with ( + _patched_docker_init(mock_docker_client=mock_docker_client, mock_entities=mock_entities), + patch( + "nemo_deployments_plugin.backends.docker.backend.find_available_port", + new_callable=AsyncMock, + return_value=9055, + ) as mock_find_port, + ): + registry = ExecutorRegistry.from_config( + sdk, + [ + ExecutorSpec( + name="local-docker", + backend="docker", + config={"port_range_start": 9050, "port_range_end": 9060, "pull_images": False}, + ), + ], + ) + backend = registry.resolve("local-docker") + assert isinstance(backend, DockerDeploymentBackend) + + mock_entities.get.return_value = DeploymentConfig( + name="cfg1", + workspace="default", + containers=[ + Container( + name="main", + image="nginx:alpine", + ports=[ContainerPort(containerPort=80, protocol="TCP", name="http")], + ) + ], + ) + mock_docker_client.containers.run.return_value = MagicMock(id="abc123") + + update = await backend.create_deployment( + workspace="default", + name="srv", + config_name="cfg1", + labels={}, + backend_config={}, + ) + + assert update.status == "STARTING" + mock_find_port.assert_awaited() + call_args = mock_find_port.await_args + assert call_args is not None + assert call_args.args[1:3] == (9050, 9060)