Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions plugins/nemo-deployments/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
8 changes: 0 additions & 8 deletions plugins/nemo-deployments/openapi/openapi.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from __future__ import annotations

from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator


class DockerExecutorConfig(BaseModel):
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,6 @@ class Affinity(BaseModel):


class DockerDeploymentConfig(BaseModel):
port_range_start: int = 9000
port_range_end: int = 9100
network: str | None = None


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@
Container,
ContainerPort,
Deployment,
DeploymentBackendConfig,
DeploymentConfig,
DockerDeploymentConfig,
)

import docker
Expand Down Expand Up @@ -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)
),
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@
from nemo_deployments_plugin.entities import (
Container,
Deployment,
DeploymentBackendConfig,
DeploymentConfig,
DockerDeploymentConfig,
Prerequisite,
Volume,
VolumeMount,
Expand Down Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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


Expand All @@ -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
103 changes: 92 additions & 11 deletions plugins/nemo-deployments/tests/unit/test_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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() == []
Expand Down Expand Up @@ -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)