Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
28 changes: 28 additions & 0 deletions plugins/nemo-agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,28 @@ The packaging command runs locally; Platform services are not required.
|---|---|
| Docker | A running Docker-compatible daemon |
| Container dependencies | Install with `uv sync --package nemo-agents-plugin --extra container` from the repository root |
| A released `nemo-platform` (Fabric only) | Fabric images pin the installed `nemo-platform` version and resolve it from an index. A source checkout reports a setuptools-scm version such as `0.3.0.post402.dev0+062f0ac6e8`, which no index serves, so packaging stops before building. See below. |

##### Packaging a Fabric agent from a source checkout
Comment thread
coderabbitai[bot] marked this conversation as resolved.

`nemo agents package` renders `uv pip install "nemo-platform[nemo-agents-plugin]==<version>"`
into the Fabric image, where `<version>` is whatever is installed on the build
host. Working from a checkout, that version carries a local build identifier,
which PEP 440 keeps off public indexes, so the command fails immediately:

```text
Error: The installed nemo-platform version '0.3.0.post402.dev0+062f0ac6e8' is a
local build identifier, which package indexes do not serve.
```

Install a released `nemo-platform` to package a Fabric agent. If you build
against an index that does serve the version, set
`NEMO_AGENTS_ALLOW_UNPUBLISHED_CONTRACT_VERSION=1` to proceed anyway. Supplying
your own `--template` also skips the check, since a custom template need not
pin the contract version at all.
Comment thread
marcusds marked this conversation as resolved.
Outdated

NAT packaging is unaffected — it installs the packaged project plus a published
`nvidia-nat` release.

#### Progressive pipeline

Expand Down Expand Up @@ -367,6 +389,12 @@ The old shared environment variables have been replaced:
| `NAT_BASE_IMAGE_TAG` | `NEMO_AGENTS_BASE_IMAGE_TAG` |
| `NAT_PYTHON_VERSION` | `NEMO_AGENTS_PYTHON_VERSION` |

Additional environment variables:

| Variable | Effect |
|---|---|
| `NEMO_AGENTS_ALLOW_UNPUBLISHED_CONTRACT_VERSION` | Set to `1` to let Fabric packaging pin an unpublished `nemo-platform` version (a local build identifier or a `.dev` release). Use only when the build's index serves that version. Does not apply when `nemo-platform` is not installed at all. |

There are no compatibility aliases. `NAT_VERSION` remains available only for
NAT workflow packaging.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@
from __future__ import annotations

import os
import re
from dataclasses import dataclass, field, fields
from pathlib import Path
from typing import Any

import jinja2

Expand All @@ -40,6 +42,12 @@
DOCKERIGNORE_SENTINEL = "# Managed by `nemo agents package` — safe to delete if you take ownership."
DOCKERFILE_SENTINEL = "# Managed by `nemo agents package` — safe to delete if you take ownership."

UNRESOLVED_CONTRACT_VERSION = "0.0.0"

#: PEP 440 dev segment. The separator is optional and the spelling is case
#: insensitive, so ``1.2.3dev0`` and ``1.2.3.DEV0`` both normalize to ``1.2.3.dev0``.
_DEV_SEGMENT = re.compile(r"[-_.]?dev[-_.]?[0-9]*$", re.IGNORECASE)


def is_plugin_managed(path: Path) -> bool:
"""Return True if *path* exists and its first line matches the sentinel.
Expand Down Expand Up @@ -425,9 +433,10 @@ def _jinja_env() -> jinja2.Environment:
undefined=jinja2.StrictUndefined,
)
env.filters["dockerfile_escape"] = _dockerfile_escape
env.globals["pinned_nemo_relay_cli_version"] = PINNED_NEMO_RELAY_CLI_VERSION
env.globals["pinned_nemo_relay_installer_commit"] = PINNED_NEMO_RELAY_INSTALLER_COMMIT
env.globals["pinned_nemo_relay_installer_sha256"] = PINNED_NEMO_RELAY_INSTALLER_SHA256
template_globals: dict[str, Any] = env.globals
template_globals["pinned_nemo_relay_cli_version"] = PINNED_NEMO_RELAY_CLI_VERSION
template_globals["pinned_nemo_relay_installer_commit"] = PINNED_NEMO_RELAY_INSTALLER_COMMIT
template_globals["pinned_nemo_relay_installer_sha256"] = PINNED_NEMO_RELAY_INSTALLER_SHA256
return env


Expand Down Expand Up @@ -634,11 +643,10 @@ def render_fabric_dockerfile(
agent_author=agent_author,
metadata=metadata,
)
if shared.contract_version == "0.0.0":
raise ValueError(
"Unable to resolve the installed nemo-platform contract version; "
"Fabric packaging requires an installed release version."
)
require_installable_contract_version(
shared.contract_version,
pins_contract_version=template_path is None,
)
params = FabricRenderParams(**{f.name: getattr(shared, f.name) for f in fields(shared)})

if template_path:
Expand All @@ -660,7 +668,42 @@ def get_contract_version() -> str:
try:
return version("nemo-platform")
except PackageNotFoundError:
return "0.0.0"
return UNRESOLVED_CONTRACT_VERSION


def require_installable_contract_version(contract_version: str, *, pins_contract_version: bool = True) -> None:
"""Reject a version no index can serve: a local identifier or a ``.dev`` release.

``pins_contract_version`` is False for a caller-supplied ``--template``,
whose contents may not pin the version at all.
"""
if contract_version == UNRESOLVED_CONTRACT_VERSION:
raise ValueError(
"Unable to resolve the installed nemo-platform contract version; "
"Fabric packaging requires an installed release version."
)

if not pins_contract_version:
return

if os.environ.get("NEMO_AGENTS_ALLOW_UNPUBLISHED_CONTRACT_VERSION", "").strip().lower() in {"1", "true", "yes"}:
return

public, _, local = contract_version.partition("+")
reasons = []
if local:
reasons.append("carries a local build identifier")
if _DEV_SEGMENT.search(public):
reasons.append("is a developmental release")
if not reasons:
return

raise ValueError(
f"The installed nemo-platform version '{contract_version}' {' and '.join(reasons)}, so no "
"package index serves it. Fabric packaging pins this exact version inside the image, so the "
"build would fail while resolving it. Install a released nemo-platform to package an agent, "
"or set NEMO_AGENTS_ALLOW_UNPUBLISHED_CONTRACT_VERSION=1 if your index serves this version."
)


def render_dockerignore(output_dir: Path) -> Path | None:
Expand Down
8 changes: 8 additions & 0 deletions plugins/nemo-agents/tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,11 @@ def ctx(tmp_path: Path) -> JobContext:
storage=StoragePaths(ephemeral=ephemeral, persistent=persistent),
results=LocalJobResults(root=persistent / "results"),
)


@pytest.fixture(autouse=True)
def _released_contract_version(monkeypatch: pytest.MonkeyPatch) -> None:
"""A source checkout reports a version the packaging guard rejects."""
import nemo_agents_plugin.container.template as template

monkeypatch.setattr(template, "get_contract_version", lambda: "1.0.0")
109 changes: 109 additions & 0 deletions plugins/nemo-agents/tests/unit/test_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -2567,3 +2567,112 @@ def test_cli_path_is_unnamespaced(self, mock_build: MagicMock, fabric_agent_conf
build_fabric_agent_image(fabric_agent_config, tag="my-agent:1.0", skip_validation=True)

assert mock_build.call_args.kwargs["tag"] == "my-agent:1.0"


class TestInstallableContractVersion:
@pytest.mark.parametrize(
"version",
[
"0.3.0.post402.dev0+062f0ac6e8", # setuptools-scm from a source checkout
"0.3.0+local",
"1.2.3.dev0",
"1.2.3dev0",
"1.2.3-dev0",
"1.2.3_dev0",
# PEP 440 normalizes case, so these are dev releases too.
"1.2.3.DEV0",
"1.2.3DEV0",
"1.2.3-DEV0",
"1.2.3_DEV0",
],
)
def test_unpublishable_versions_are_rejected(self, version: str) -> None:
from nemo_agents_plugin.container.template import require_installable_contract_version

with pytest.raises(ValueError, match="Fabric packaging pins this exact version"):
require_installable_contract_version(version)

def test_a_checkout_version_reports_both_reasons(self) -> None:
from nemo_agents_plugin.container.template import require_installable_contract_version

with pytest.raises(ValueError) as excinfo:
require_installable_contract_version("0.3.0.post402.dev0+062f0ac6e8")

assert "carries a local build identifier and is a developmental release" in str(excinfo.value)

@pytest.mark.parametrize("version", ["0.3.0", "0.4.0", "1.0.0.post1", "0.4.0rc1"])
def test_published_versions_are_accepted(self, version: str) -> None:
from nemo_agents_plugin.container.template import require_installable_contract_version

require_installable_contract_version(version)

def test_missing_install_keeps_its_own_message(self) -> None:
from nemo_agents_plugin.container.template import (
UNRESOLVED_CONTRACT_VERSION,
require_installable_contract_version,
)

with pytest.raises(ValueError, match="Unable to resolve the installed nemo-platform contract version"):
require_installable_contract_version(UNRESOLVED_CONTRACT_VERSION)

@pytest.mark.parametrize("value", ["1", "true", "YES"])
def test_env_override_allows_an_unpublished_version(self, value: str, monkeypatch: pytest.MonkeyPatch) -> None:
from nemo_agents_plugin.container.template import require_installable_contract_version

monkeypatch.setenv("NEMO_AGENTS_ALLOW_UNPUBLISHED_CONTRACT_VERSION", value)
require_installable_contract_version("0.3.0.post402.dev0+062f0ac6e8")

def test_env_override_ignores_unset_and_falsey(self, monkeypatch: pytest.MonkeyPatch) -> None:
from nemo_agents_plugin.container.template import require_installable_contract_version

monkeypatch.setenv("NEMO_AGENTS_ALLOW_UNPUBLISHED_CONTRACT_VERSION", "0")
with pytest.raises(ValueError):
require_installable_contract_version("0.3.0+local")

def test_override_cannot_bypass_an_unresolved_version(self, monkeypatch: pytest.MonkeyPatch) -> None:
from nemo_agents_plugin.container.template import (
UNRESOLVED_CONTRACT_VERSION,
require_installable_contract_version,
)

monkeypatch.setenv("NEMO_AGENTS_ALLOW_UNPUBLISHED_CONTRACT_VERSION", "1")

with pytest.raises(ValueError, match="Unable to resolve the installed nemo-platform contract version"):
require_installable_contract_version(UNRESOLVED_CONTRACT_VERSION)

def test_custom_template_may_use_an_unpublished_version(self) -> None:
from nemo_agents_plugin.container.template import require_installable_contract_version

require_installable_contract_version("0.3.0.post402.dev0+062f0ac6e8", pins_contract_version=False)

def test_custom_template_still_needs_an_installed_platform(self) -> None:
from nemo_agents_plugin.container.template import (
UNRESOLVED_CONTRACT_VERSION,
require_installable_contract_version,
)

with pytest.raises(ValueError, match="Unable to resolve"):
require_installable_contract_version(UNRESOLVED_CONTRACT_VERSION, pins_contract_version=False)

def test_render_with_custom_template_allows_a_checkout_version(
self, tmp_path: Path, fabric_agent_config: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
import nemo_agents_plugin.container.template as template

monkeypatch.setattr(template, "get_contract_version", lambda: "0.3.0.post402.dev0+062f0ac6e8")
custom = tmp_path / "custom.j2"
custom.write_text("FROM scratch\nLABEL agent={{ agent_name }}\n")

result = template.render_fabric_dockerfile(fabric_agent_config, template_path=str(custom))

assert "FROM scratch" in result

def test_render_rejects_a_source_checkout_version(
self, fabric_agent_config: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
import nemo_agents_plugin.container.template as template

monkeypatch.setattr(template, "get_contract_version", lambda: "0.3.0.post402.dev0+062f0ac6e8")

with pytest.raises(ValueError, match="local build identifier"):
template.render_fabric_dockerfile(fabric_agent_config)
Loading