From 8be99cb7743e57bd9db7396b7caa6be1727beb3a Mon Sep 17 00:00:00 2001 From: Manjesh Mogallapalli Date: Fri, 31 Jul 2026 16:39:47 -0500 Subject: [PATCH 01/13] add initial wiring for fabric support Signed-off-by: Manjesh Mogallapalli --- .../nemo-agents/src/nemo_agents_plugin/cli.py | 85 ++++++++--- .../nemo_agents_plugin/container/builder.py | 74 ++++++++- .../nemo-agents/tests/unit/test_container.py | 143 +++++++++++++----- 3 files changed, 239 insertions(+), 63 deletions(-) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/cli.py b/plugins/nemo-agents/src/nemo_agents_plugin/cli.py index 4db4c1abb5..1d00fca041 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/cli.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/cli.py @@ -426,11 +426,29 @@ def package( template=template, platform=platform, ) - _warn_if_nat_version_unpinned(nat_version) + + from nemo_agents_plugin.container.builder import detect_agent_config_format + + try: + config_format = detect_agent_config_format(agent) + except ValueError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(code=1) + + if config_format == NAT_WORKFLOW_CONFIG_FORMAT: + _warn_if_nat_version_unpinned(nat_version) + elif config_format == NEMO_AGENTS_SPEC_CONFIG_FORMAT and nat_version is not None: + typer.echo( + "Error: --nat-version is only valid for NAT workflow packaging; " + "Fabric agent packaging does not use NAT_VERSION.", + err=True, + ) + raise typer.Exit(code=1) if no_build: _package_render_only( agent_config=agent, + config_format=config_format, pyproject=pyproject, output=output, format=format, @@ -448,28 +466,48 @@ def package( ) return - from nemo_agents_plugin.container.builder import build_agent_image + from nemo_agents_plugin.container.builder import build_fabric_agent_image, build_nat_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, - sandbox_runtime=sandbox_runtime, - agent_version=agent_version, - agent_author=agent_author, - template_path=template, - skip_validation=skip_validation, - generate_ignore=generate_ignore, - platforms=platform, - ) + if config_format == NEMO_AGENTS_SPEC_CONFIG_FORMAT: + result_tag = build_fabric_agent_image( + agent, + pyproject=pyproject, + dockerfile=dockerfile, + tag=tag, + base_image_url=base_image_url, + base_image_tag=base_image_tag, + python_version=python_version, + uv_version=uv_version, + allow_root=allow_root, + sandbox_runtime=sandbox_runtime, + agent_version=agent_version, + agent_author=agent_author, + template_path=template, + skip_validation=skip_validation, + generate_ignore=generate_ignore, + platforms=platform, + ) + else: + result_tag = build_nat_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, + sandbox_runtime=sandbox_runtime, + 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) @@ -570,6 +608,7 @@ def _warn_if_nat_version_unpinned(nat_version: Optional[str]) -> None: def _package_render_only( *, agent_config: Path, + config_format: str, pyproject: Optional[Path], output: Optional[Path], format: str, @@ -590,6 +629,10 @@ def _package_render_only( # before we get here; assert for the developer who deletes that guard. assert format == "docker", f"unreachable: format={format!r}" + if config_format == NEMO_AGENTS_SPEC_CONFIG_FORMAT: + typer.echo("Error: Fabric agent packaging is not implemented yet.", err=True) + raise typer.Exit(code=1) + from nemo_agents_plugin.container.template import render_dockerfile, render_dockerignore try: diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py b/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py index a31c41ab90..700c4ee3b9 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Docker image builder for NAT agents. +"""Docker image builder for NeMo Platform 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`. @@ -18,6 +18,11 @@ from pathlib import Path import typer +import yaml +from nemo_agents_plugin.entities import ( + NAT_WORKFLOW_CONFIG_FORMAT, + NEMO_AGENTS_SPEC_CONFIG_FORMAT, +) logger = logging.getLogger(__name__) @@ -87,7 +92,7 @@ def docker_build( return tag -def build_agent_image( +def build_nat_agent_image( agent_config: Path, pyproject: Path | None = None, dockerfile: Path | None = None, @@ -108,7 +113,7 @@ def build_agent_image( platforms: list[str] | None = None, push: bool = False, ) -> str: - """High-level helper: validate, render (if needed), then build. + """High-level helper: validate, render (if needed), then build a NAT image. When *dockerfile* is ``None``, a Dockerfile is rendered via the template module and written into a temporary file inside the build context. @@ -243,6 +248,69 @@ def build_agent_image( ignore_file.unlink(missing_ok=True) +def build_fabric_agent_image( + agent_config: Path, + pyproject: Path | None = None, + dockerfile: Path | None = None, + tag: 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, + sandbox_runtime: str | None = None, + 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: + """Build a Fabric-backed NeMo agent image. + + This is intentionally separate from ``build_nat_agent_image`` so Fabric + packaging can grow without inheriting NAT-specific args such as + ``nat_version`` or ``NAT_CONFIG_FILE``. + """ + del ( + agent_config, + pyproject, + dockerfile, + tag, + base_image_url, + base_image_tag, + python_version, + uv_version, + allow_root, + sandbox_runtime, + agent_version, + agent_author, + template_path, + skip_validation, + generate_ignore, + platforms, + push, + ) + raise ValueError("Fabric agent packaging is not implemented yet.") + + +def detect_agent_config_format(agent_config: Path) -> str: + try: + data = yaml.safe_load(agent_config.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + raise ValueError(f"YAML parse error in agent config {agent_config}: {exc}") from exc + + if not isinstance(data, dict): + raise ValueError("Agent config root must be a YAML mapping.") + + config_format = data.get("config_format", NAT_WORKFLOW_CONFIG_FORMAT) + if config_format not in {NAT_WORKFLOW_CONFIG_FORMAT, NEMO_AGENTS_SPEC_CONFIG_FORMAT}: + raise ValueError(f"Unsupported agent config format: {config_format!r}") + return config_format + + def _emit_refusal_error(path: Path) -> int: """Emit a uniform refuse-to-overwrite error and return the exit code.""" typer.echo( diff --git a/plugins/nemo-agents/tests/unit/test_container.py b/plugins/nemo-agents/tests/unit/test_container.py index ae091495f0..f02d5567ee 100644 --- a/plugins/nemo-agents/tests/unit/test_container.py +++ b/plugins/nemo-agents/tests/unit/test_container.py @@ -843,16 +843,51 @@ def test_malformed_workflow_fields_are_rejected(self, tmp_path: Path) -> None: # --------------------------------------------------------------------------- +class TestDetectAgentConfigFormat: + def test_missing_config_format_defaults_to_nat(self, agent_config: Path) -> None: + from nemo_agents_plugin.container.builder import detect_agent_config_format + from nemo_agents_plugin.entities import NAT_WORKFLOW_CONFIG_FORMAT + + assert detect_agent_config_format(agent_config) == NAT_WORKFLOW_CONFIG_FORMAT + + def test_detects_platform_agent_spec(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.builder import detect_agent_config_format + from nemo_agents_plugin.entities import NEMO_AGENTS_SPEC_CONFIG_FORMAT + + config = tmp_path / "agent.yaml" + config.write_text("config_format: nemo-agents-spec-v1\nname: fabric-agent\n") + + assert detect_agent_config_format(config) == NEMO_AGENTS_SPEC_CONFIG_FORMAT + + def test_rejects_unknown_config_format(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.builder import detect_agent_config_format + + config = tmp_path / "agent.yaml" + config.write_text("config_format: future-format-v99\n") + + with pytest.raises(ValueError, match="Unsupported agent config format"): + detect_agent_config_format(config) + + def test_reports_yaml_parse_errors(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.builder import detect_agent_config_format + + config = tmp_path / "agent.yaml" + config.write_text("config_format: [unterminated\n") + + with pytest.raises(ValueError, match="YAML parse error"): + detect_agent_config_format(config) + + 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 + from nemo_agents_plugin.container.builder import build_nat_agent_image dockerfile = agent_config.parent / "Dockerfile" dockerfile.write_text("FROM ubuntu") mock_build.return_value = "my-agent:latest" - result = build_agent_image( + result = build_nat_agent_image( agent_config, dockerfile=dockerfile, tag="my-agent:latest", @@ -865,11 +900,11 @@ def test_build_with_provided_dockerfile(self, mock_build: MagicMock, agent_confi @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 + from nemo_agents_plugin.container.builder import build_nat_agent_image mock_build.return_value = "config-abc123:0.0.0" - build_agent_image(agent_config, nat_version="1.4.0", agent_author="x") + build_nat_agent_image(agent_config, nat_version="1.4.0", agent_author="x") tag = mock_build.call_args.kwargs["tag"] assert tag.startswith("config-") @@ -879,10 +914,10 @@ def test_build_renders_on_the_fly(self, mock_build: MagicMock, agent_config: Pat @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 + from nemo_agents_plugin.container.builder import build_nat_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") + build_nat_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() @@ -902,7 +937,7 @@ def test_build_preserves_committed_plugin_managed_dockerignore( 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.builder import build_nat_agent_image from nemo_agents_plugin.container.template import DOCKERIGNORE_SENTINEL ignore = agent_config.parent / ".dockerignore" @@ -910,7 +945,7 @@ def test_build_preserves_committed_plugin_managed_dockerignore( 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") + build_nat_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 @@ -920,22 +955,22 @@ def test_build_preserves_committed_plugin_managed_dockerignore( @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 + from nemo_agents_plugin.container.builder import build_nat_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") + build_nat_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 + from nemo_agents_plugin.container.builder import build_nat_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") + build_nat_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}" @@ -946,32 +981,32 @@ def test_default_tag_from_metadata(self, mock_build: MagicMock, project_dir: tup @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 + from nemo_agents_plugin.container.builder import build_nat_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") + build_nat_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 + from nemo_agents_plugin.container.builder import build_nat_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) + result = build_nat_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 + from nemo_agents_plugin.container.builder import build_nat_agent_image mock_build.return_value = "x:latest" - build_agent_image(agent_config, nat_version="1.0.0", allow_root=True) + build_nat_agent_image(agent_config, nat_version="1.0.0", allow_root=True) call_kwargs = mock_build.call_args.kwargs dockerfile = call_kwargs["dockerfile"] @@ -979,25 +1014,25 @@ def test_build_passes_allow_root(self, mock_build: MagicMock, agent_config: Path @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 + from nemo_agents_plugin.container.builder import build_nat_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)) + build_nat_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 + from nemo_agents_plugin.container.builder import build_nat_agent_image mock_build.side_effect = SystemExit(1) with pytest.raises((SystemExit, ClickExit)): - build_agent_image(agent_config, nat_version="1.0.0") + build_nat_agent_image(agent_config, nat_version="1.0.0") assert not (agent_config.parent / "Dockerfile.generated").exists() assert not (agent_config.parent / ".dockerignore").exists() @@ -1138,7 +1173,7 @@ class TestEndToEndPipeline: @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.builder import build_nat_agent_image from nemo_agents_plugin.container.template import render_dockerfile, render_dockerignore rendered = render_dockerfile( @@ -1161,7 +1196,7 @@ def test_render_then_build_then_publish(self, mock_build: MagicMock, agent_confi assert ignore_path.exists() mock_build.return_value = "e2e-agent:1.0.0" - tag = build_agent_image( + tag = build_nat_agent_image( agent_config, dockerfile=dockerfile_path, tag="e2e-agent:1.0.0", @@ -1190,7 +1225,7 @@ def test_render_then_build_then_publish(self, mock_build: MagicMock, agent_confi @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.builder import build_nat_agent_image from nemo_agents_plugin.container.validator import validate_agent_config config, pyproject = project_dir @@ -1199,7 +1234,7 @@ def test_full_pipeline_with_project_mode(self, mock_build: MagicMock, project_di assert validation.valid mock_build.return_value = "placeholder" - build_agent_image( + build_nat_agent_image( config, pyproject=pyproject, nat_version="1.4.0", @@ -1216,13 +1251,13 @@ def test_full_pipeline_with_project_mode(self, mock_build: MagicMock, project_di @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 + from nemo_agents_plugin.container.builder import build_nat_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") + build_nat_agent_image(bad, nat_version="1.0.0") mock_build.assert_not_called() @@ -1250,10 +1285,10 @@ def test_external_template_e2e(self, mock_build: MagicMock, agent_config: Path, @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 + from nemo_agents_plugin.container.builder import build_nat_agent_image mock_build.return_value = "root-agent:latest" - build_agent_image(agent_config, nat_version="1.0.0", allow_root=True) + build_nat_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 "" @@ -1295,7 +1330,7 @@ def test_no_build_renders_dockerfile_and_ignore(self, package_cli, agent_config: output = tmp_path / "Dockerfile" with ( - patch("nemo_agents_plugin.container.builder.build_agent_image") as mock_build, + patch("nemo_agents_plugin.container.builder.build_nat_agent_image") as mock_build, patch("nemo_agents_plugin.container.publisher.docker_push") as mock_push, ): result = runner.invoke( @@ -1412,7 +1447,7 @@ def test_default_runs_build_without_publish(self, package_cli, agent_config: Pat app, runner = package_cli with ( - patch("nemo_agents_plugin.container.builder.build_agent_image") as mock_build, + patch("nemo_agents_plugin.container.builder.build_nat_agent_image") as mock_build, patch("nemo_agents_plugin.container.publisher.docker_push") as mock_push, ): mock_build.return_value = "my-agent:1.0" @@ -1435,12 +1470,42 @@ def test_default_runs_build_without_publish(self, package_cli, agent_config: Pat assert mock_build.call_args.kwargs["tag"] == "my-agent:1.0" mock_push.assert_not_called() + def test_fabric_config_routes_only_to_fabric_builder(self, package_cli, tmp_path: Path) -> None: + """Fabric configs select the Fabric builder without NAT-only arguments.""" + app, runner = package_cli + agent_config = tmp_path / "agent.yaml" + agent_config.write_text("config_format: nemo-agents-spec-v1\nname: fabric-agent\n") + + with ( + patch("nemo_agents_plugin.container.builder.build_fabric_agent_image") as mock_fabric_build, + patch("nemo_agents_plugin.container.builder.build_nat_agent_image") as mock_nat_build, + ): + mock_fabric_build.return_value = "fabric-agent:dev" + result = runner.invoke( + app, + [ + "package", + "--agent", + str(agent_config), + "--tag", + "fabric-agent:dev", + ], + ) + + assert result.exit_code == 0, result.stdout + assert "Image ready: fabric-agent:dev" in result.stdout + mock_fabric_build.assert_called_once() + mock_nat_build.assert_not_called() + assert mock_fabric_build.call_args.args == (agent_config,) + assert mock_fabric_build.call_args.kwargs["tag"] == "fabric-agent:dev" + assert "nat_version" not in mock_fabric_build.call_args.kwargs + 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.builder.build_nat_agent_image") as mock_build, patch("nemo_agents_plugin.container.publisher.docker_push") as mock_push, ): mock_build.return_value = "my-agent:1.0" @@ -1473,7 +1538,7 @@ def test_publish_without_registry_fails(self, package_cli, agent_config: Path) - app, runner = package_cli with ( - patch("nemo_agents_plugin.container.builder.build_agent_image") as mock_build, + patch("nemo_agents_plugin.container.builder.build_nat_agent_image") as mock_build, patch("nemo_agents_plugin.container.publisher.docker_push") as mock_push, ): result = runner.invoke( @@ -1491,7 +1556,7 @@ def test_no_build_and_publish_are_mutually_exclusive(self, package_cli, agent_co app, runner = package_cli with ( - patch("nemo_agents_plugin.container.builder.build_agent_image") as mock_build, + patch("nemo_agents_plugin.container.builder.build_nat_agent_image") as mock_build, patch("nemo_agents_plugin.container.publisher.docker_push") as mock_push, ): result = runner.invoke( @@ -1537,7 +1602,7 @@ def test_whl_format_rejected_in_every_mode(self, package_cli, agent_config: Path """ app, runner = package_cli - with patch("nemo_agents_plugin.container.builder.build_agent_image") as mock_build: + with patch("nemo_agents_plugin.container.builder.build_nat_agent_image") as mock_build: no_build_result = runner.invoke( app, ["package", "--agent", str(agent_config), "--format", "whl", "--no-build"], @@ -1717,7 +1782,7 @@ def test_multi_platform_rejected_with_actionable_error(self, package_cli, agent_ """ app, runner = package_cli - with patch("nemo_agents_plugin.container.builder.build_agent_image") as mock_build: + with patch("nemo_agents_plugin.container.builder.build_nat_agent_image") as mock_build: result = runner.invoke( app, [ @@ -1848,13 +1913,13 @@ def test_builder_refuses_pre_existing_dockerfile_generated(self, tmp_path: Path, 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 + from nemo_agents_plugin.container.builder import build_nat_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") + build_nat_agent_image(agent_config, nat_version="1.4.0", agent_author="x") assert user_file.exists() assert "USER OWNED" in user_file.read_text() From 3058f82abfc49d3932c7f7c30311a1188b668185 Mon Sep 17 00:00:00 2001 From: Manjesh Mogallapalli Date: Sat, 1 Aug 2026 14:30:22 -0500 Subject: [PATCH 02/13] generalize env vars & split render contracts Signed-off-by: Manjesh Mogallapalli --- .../nemo-agents/src/nemo_agents_plugin/cli.py | 19 +- .../nemo_agents_plugin/container/builder.py | 6 +- .../nemo_agents_plugin/container/template.py | 164 +++++++----- .../nemo-agents/tests/unit/test_container.py | 253 ++++++++++++++---- 4 files changed, 319 insertions(+), 123 deletions(-) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/cli.py b/plugins/nemo-agents/src/nemo_agents_plugin/cli.py index 1d00fca041..1c2ce40f75 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/cli.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/cli.py @@ -364,20 +364,25 @@ def package( "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"), + base_image_url: Optional[str] = typer.Option( + None, "--base-image-url", envvar="NEMO_AGENTS_BASE_IMAGE_URL" + ), + base_image_tag: Optional[str] = typer.Option( + None, "--base-image-tag", envvar="NEMO_AGENTS_BASE_IMAGE_TAG" + ), + python_version: Optional[str] = typer.Option( + None, "--python-version", envvar="NEMO_AGENTS_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"), + uv_version: Optional[str] = typer.Option(None, "--uv-version", envvar="NEMO_AGENTS_UV_VERSION"), allow_root: bool = typer.Option( False, "--allow-root", help="Disable non-root USER hardening in the rendered Dockerfile." ), @@ -633,10 +638,10 @@ def _package_render_only( typer.echo("Error: Fabric agent packaging is not implemented yet.", err=True) raise typer.Exit(code=1) - from nemo_agents_plugin.container.template import render_dockerfile, render_dockerignore + from nemo_agents_plugin.container.template import render_dockerignore, render_nat_dockerfile try: - content = render_dockerfile( + content = render_nat_dockerfile( agent_config, pyproject, base_image_url=base_image_url, diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py b/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py index 700c4ee3b9..f7bc229f5b 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py @@ -4,7 +4,7 @@ """Docker image builder for NeMo Platform 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`. +one on-the-fly via :func:`~nemo_agents_plugin.container.template.render_nat_dockerfile`. Uses `python-on-whales `_ for Docker operations so callers never need to shell out manually. @@ -122,7 +122,7 @@ def build_nat_agent_image( 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.template import render_dockerignore, render_nat_dockerfile, resolve_value from nemo_agents_plugin.container.validator import validate_agent_config if not skip_validation: @@ -189,7 +189,7 @@ def build_nat_agent_image( push=push, ) - content = render_dockerfile( + content = render_nat_dockerfile( agent_config, pyproject, base_image_url=base_image_url, diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/container/template.py b/plugins/nemo-agents/src/nemo_agents_plugin/container/template.py index 28ac12fbbd..87c233a321 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/container/template.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/container/template.py @@ -1,10 +1,12 @@ # 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. +"""Jinja2-based Dockerfile rendering primitives for packaged agents. -Renders a Dockerfile from a built-in template using values resolved from -CLI flags, environment variables, or sensible defaults. +The current built-in template targets NAT agents. Shared render parameters +are kept separate from NAT-specific parameters so Fabric packaging can reuse +the image, project, sandbox, and metadata contract without inheriting NAT +runtime fields. Two rendering modes are supported: @@ -80,16 +82,16 @@ def is_plugin_managed(path: Path) -> bool: } _ENV_MAP: dict[str, str] = { - "base_image_url": "NAT_BASE_IMAGE_URL", - "base_image_tag": "NAT_BASE_IMAGE_TAG", - "python_version": "NAT_PYTHON_VERSION", + "base_image_url": "NEMO_AGENTS_BASE_IMAGE_URL", + "base_image_tag": "NEMO_AGENTS_BASE_IMAGE_TAG", + "python_version": "NEMO_AGENTS_PYTHON_VERSION", "nat_version": "NAT_VERSION", - "uv_version": "NAT_UV_VERSION", + "uv_version": "NEMO_AGENTS_UV_VERSION", } # -- Jinja2 template -------------------------------------------------------- -DOCKERFILE_TEMPLATE = ( +NAT_DOCKERFILE_TEMPLATE = ( f"""\ {DOCKERFILE_SENTINEL} """ @@ -208,17 +210,16 @@ def is_plugin_managed(path: Path) -> bool: """ -# -- Data class for render parameters -------------------------------------- +# -- Data classes for render parameters ------------------------------------ @dataclass -class RenderParams: - """Resolved parameters for Dockerfile rendering.""" +class SharedRenderParams: + """Resolved Dockerfile parameters shared by all agent runtimes.""" 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" @@ -240,6 +241,13 @@ class RenderParams: extra: dict[str, str] = field(default_factory=dict) +@dataclass +class NatRenderParams(SharedRenderParams): + """Resolved parameters specific to NAT Dockerfile rendering.""" + + nat_version: str = "" + + # -- Public API ------------------------------------------------------------- @@ -314,54 +322,21 @@ def _jinja_env() -> jinja2.Environment: return env -def render_dockerfile( +def resolve_shared_render_params( 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, sandbox_runtime: str | None = None, 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. - sandbox_runtime: Name of a registered sandbox runtime (e.g. - ``"openshell"``). When set, the runtime's image profile is - discovered via the ``nemo.sandbox_profiles`` entry-point group and - its required apt packages + users are baked into the image. - 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). - """ +) -> SharedRenderParams: + """Resolve image, project, sandbox, and metadata fields shared by runtimes.""" from nemo_agents_plugin.container.metadata import extract_agent_metadata has_pyproject = pyproject is not None and pyproject.exists() @@ -371,11 +346,6 @@ def render_dockerfile( 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 " @@ -386,9 +356,6 @@ def render_dockerfile( else: config_file_path = f"/workspace/{agent_config.name}" - resolved_nat = resolve_value("nat_version", nat_version) - contract_version = _get_contract_version() - sandbox_runtime_name = "" sandbox_apt_packages = "" sandbox_user_setup = "" @@ -412,11 +379,10 @@ def render_dockerfile( agent_author=agent_author, ) - params = RenderParams( + return SharedRenderParams( 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, @@ -424,7 +390,7 @@ def render_dockerfile( sandbox_runtime=sandbox_runtime_name, sandbox_apt_packages=sandbox_apt_packages, sandbox_user_setup=sandbox_user_setup, - contract_version=contract_version, + contract_version=_get_contract_version(), agent_id=metadata["agent_id"], agent_name=metadata["agent_name"], agent_version=metadata["agent_version"], @@ -437,6 +403,80 @@ def render_dockerfile( source=metadata["source"], ) + +def _render_context(params: SharedRenderParams) -> dict[str, object]: + """Flatten shared and runtime-specific dataclass fields for Jinja.""" + ctx = {f.name: getattr(params, f.name) for f in fields(params) if f.name != "extra"} + ctx.update(params.extra) + return ctx + + +def render_nat_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, + sandbox_runtime: str | None = None, + 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. + sandbox_runtime: Name of a registered sandbox runtime (e.g. + ``"openshell"``). When set, the runtime's image profile is + discovered via the ``nemo.sandbox_profiles`` entry-point group and + its required apt packages + users are baked into the image. + 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). + """ + shared = resolve_shared_render_params( + agent_config, + pyproject, + base_image_url=base_image_url, + base_image_tag=base_image_tag, + python_version=python_version, + uv_version=uv_version, + allow_root=allow_root, + sandbox_runtime=sandbox_runtime, + agent_version=agent_version, + agent_author=agent_author, + metadata=metadata, + ) + params = NatRenderParams( + **{f.name: getattr(shared, f.name) for f in fields(shared)}, + nat_version=resolve_value("nat_version", nat_version), + ) + if template_path: # Convert filesystem failures into the documented ``ValueError`` # contract. ``_validate_package_flags`` already rejects a missing @@ -450,12 +490,10 @@ def render_dockerfile( except (OSError, UnicodeDecodeError) as exc: raise ValueError(f"failed to read --template file {template_path}: {exc}") from exc else: - template_source = DOCKERFILE_TEMPLATE + template_source = NAT_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) + return template.render(**_render_context(params)) def _get_contract_version() -> str: diff --git a/plugins/nemo-agents/tests/unit/test_container.py b/plugins/nemo-agents/tests/unit/test_container.py index f02d5567ee..e3c50314ef 100644 --- a/plugins/nemo-agents/tests/unit/test_container.py +++ b/plugins/nemo-agents/tests/unit/test_container.py @@ -59,13 +59,23 @@ def project_dir(tmp_path: Path) -> tuple[Path, Path]: # --------------------------------------------------------------------------- -class TestRenderDockerfile: - """Tests for nemo_agents_plugin.container.template.""" +class TestRenderNatDockerfile: + """Tests for the NAT Dockerfile renderer.""" + + def test_nat_params_extend_shared_contract(self) -> None: + from nemo_agents_plugin.container.template import NatRenderParams, SharedRenderParams + + shared = SharedRenderParams() + nat = NatRenderParams(nat_version="1.8.0") + + assert isinstance(nat, SharedRenderParams) + assert not hasattr(shared, "nat_version") + assert nat.nat_version == "1.8.0" def test_config_only_mode(self, agent_config: Path) -> None: - from nemo_agents_plugin.container.template import render_dockerfile + from nemo_agents_plugin.container.template import render_nat_dockerfile - result = render_dockerfile(agent_config, None, nat_version="1.4.0") + result = render_nat_dockerfile(agent_config, None, nat_version="1.4.0") assert "nvidia-nat[most]==" in result assert "uv sync" not in result @@ -84,10 +94,10 @@ def test_project_mode(self, project_dir: tuple[Path, Path]) -> None: ``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 + from nemo_agents_plugin.container.template import render_nat_dockerfile config, pyproject = project_dir - result = render_dockerfile(config, pyproject, nat_version="1.4.0") + result = render_nat_dockerfile(config, pyproject, nat_version="1.4.0") assert "uv pip install ." in result assert ". /workspace/.venv/bin/activate" in result @@ -111,9 +121,9 @@ def test_project_mode(self, project_dir: tuple[Path, Path]) -> None: ) def test_custom_overrides(self, agent_config: Path) -> None: - from nemo_agents_plugin.container.template import render_dockerfile + from nemo_agents_plugin.container.template import render_nat_dockerfile - result = render_dockerfile( + result = render_nat_dockerfile( agent_config, None, base_image_url="custom/image", @@ -130,15 +140,21 @@ def test_custom_overrides(self, agent_config: Path) -> None: 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 + from nemo_agents_plugin.container.template import render_nat_dockerfile monkeypatch.setenv("NAT_VERSION", "1.5.0") - monkeypatch.setenv("NAT_PYTHON_VERSION", "3.11") + monkeypatch.setenv("NEMO_AGENTS_BASE_IMAGE_URL", "env/image") + monkeypatch.setenv("NEMO_AGENTS_BASE_IMAGE_TAG", "env-tag") + monkeypatch.setenv("NEMO_AGENTS_PYTHON_VERSION", "3.11") + monkeypatch.setenv("NEMO_AGENTS_UV_VERSION", "0.9.0") - result = render_dockerfile(agent_config, None) + result = render_nat_dockerfile(agent_config, None) assert "ARG NAT_VERSION=1.5.0" in result + assert "ARG BASE_IMAGE_URL=env/image" in result + assert "ARG BASE_IMAGE_TAG=env-tag" in result assert "ARG PYTHON_VERSION=3.11" in result + assert "ghcr.io/astral-sh/uv:0.9.0" in result def test_missing_nat_version_falls_back_to_default( self, agent_config: Path, monkeypatch: pytest.MonkeyPatch @@ -148,20 +164,20 @@ def test_missing_nat_version_falls_back_to_default( 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 + from nemo_agents_plugin.container.template import _DEFAULTS, render_nat_dockerfile monkeypatch.delenv("NAT_VERSION", raising=False) - result = render_dockerfile(agent_config, None) + result = render_nat_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 + from nemo_agents_plugin.container.template import render_nat_dockerfile - result = render_dockerfile(agent_config, None, nat_version="1.4.0") + result = render_nat_dockerfile(agent_config, None, nat_version="1.4.0") assert "USER agent" in result assert "groupadd" in result @@ -179,9 +195,9 @@ def test_non_root_user_by_default(self, agent_config: Path) -> None: 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 + from nemo_agents_plugin.container.template import render_nat_dockerfile - result = render_dockerfile(agent_config, None, nat_version="1.4.0", allow_root=True) + result = render_nat_dockerfile(agent_config, None, nat_version="1.4.0", allow_root=True) assert "USER agent" not in result assert "groupadd" not in result @@ -199,9 +215,9 @@ def _fake_profile(): ) def test_sandbox_runtime_off_by_default(self, agent_config: Path) -> None: - from nemo_agents_plugin.container.template import render_dockerfile + from nemo_agents_plugin.container.template import render_nat_dockerfile - result = render_dockerfile(agent_config, None, nat_version="1.4.0") + result = render_nat_dockerfile(agent_config, None, nat_version="1.4.0") # Vanilla images must not carry any sandbox-runtime extras. assert "iproute2" not in result @@ -209,14 +225,14 @@ def test_sandbox_runtime_off_by_default(self, agent_config: Path) -> None: assert "sandbox" not in result def test_sandbox_runtime_renders_discovered_profile(self, agent_config: Path) -> None: - from nemo_agents_plugin.container.template import render_dockerfile + from nemo_agents_plugin.container.template import render_nat_dockerfile # The packager discovers profiles by name; it never imports a provider. with patch( "nemo_agents_plugin.container.sandbox.discover_sandbox_profiles", return_value={"openshell": self._fake_profile()}, ): - result = render_dockerfile(agent_config, None, nat_version="1.4.0", sandbox_runtime="openshell") + result = render_nat_dockerfile(agent_config, None, nat_version="1.4.0", sandbox_runtime="openshell") assert "iproute2 nftables" in result assert "groupadd --system sandbox" in result @@ -228,20 +244,20 @@ def test_sandbox_runtime_renders_discovered_profile(self, agent_config: Path) -> assert "exec nat serve" in result def test_sandbox_runtime_unknown_raises(self, agent_config: Path) -> None: - from nemo_agents_plugin.container.template import render_dockerfile + from nemo_agents_plugin.container.template import render_nat_dockerfile with patch("nemo_agents_plugin.container.sandbox.discover_sandbox_profiles", return_value={}): with pytest.raises(ValueError, match="unknown sandbox runtime"): - render_dockerfile(agent_config, None, nat_version="1.4.0", sandbox_runtime="nope") + render_nat_dockerfile(agent_config, None, nat_version="1.4.0", sandbox_runtime="nope") def test_sandbox_runtime_independent_of_allow_root(self, agent_config: Path) -> None: - from nemo_agents_plugin.container.template import render_dockerfile + from nemo_agents_plugin.container.template import render_nat_dockerfile with patch( "nemo_agents_plugin.container.sandbox.discover_sandbox_profiles", return_value={"openshell": self._fake_profile()}, ): - result = render_dockerfile( + result = render_nat_dockerfile( agent_config, None, nat_version="1.4.0", sandbox_runtime="openshell", allow_root=True ) @@ -250,9 +266,9 @@ def test_sandbox_runtime_independent_of_allow_root(self, agent_config: Path) -> assert "USER agent" not in result def test_oci_labels_present(self, agent_config: Path) -> None: - from nemo_agents_plugin.container.template import render_dockerfile + from nemo_agents_plugin.container.template import render_nat_dockerfile - result = render_dockerfile(agent_config, None, nat_version="1.4.0") + result = render_nat_dockerfile(agent_config, None, nat_version="1.4.0") assert 'com.nemo.agent.id="' in result assert 'org.opencontainers.image.title="config"' in result @@ -264,9 +280,9 @@ def test_oci_labels_present(self, agent_config: Path) -> None: 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 + from nemo_agents_plugin.container.template import render_nat_dockerfile - result = render_dockerfile( + result = render_nat_dockerfile( agent_config, None, nat_version="1.4.0", @@ -278,16 +294,16 @@ def test_oci_labels_with_explicit_metadata(self, agent_config: Path) -> None: 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 + from nemo_agents_plugin.container.template import render_nat_dockerfile config, pyproject = project_dir - result = render_dockerfile(config, pyproject, nat_version="1.4.0") + result = render_nat_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 + from nemo_agents_plugin.container.template import render_nat_dockerfile (tmp_path / "configs").mkdir() config = tmp_path / "configs" / "config.yaml" @@ -299,22 +315,22 @@ def test_oci_labels_description_and_license_from_pyproject(self, tmp_path: Path) 'license = "Apache-2.0"\n' ) - result = render_dockerfile(config, pyproject, nat_version="1.4.0") + result = render_nat_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 + from nemo_agents_plugin.container.template import render_nat_dockerfile - result = render_dockerfile(agent_config, None, nat_version="1.4.0") + result = render_nat_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 + from nemo_agents_plugin.container.template import render_nat_dockerfile - result = render_dockerfile(agent_config, None, nat_version="1.4.0") + result = render_nat_dockerfile(agent_config, None, nat_version="1.4.0") assert "--no-install-recommends" in result assert "rm -rf /var/lib/apt/lists/*" in result @@ -326,11 +342,11 @@ def test_uv_python_outside_root(self, agent_config: Path, project_dir: tuple[Pat /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 + from nemo_agents_plugin.container.template import render_nat_dockerfile - config_only = render_dockerfile(agent_config, None, nat_version="1.4.0") + config_only = render_nat_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") + with_proj = render_nat_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 @@ -339,12 +355,12 @@ def test_uv_python_outside_root(self, agent_config: Path, project_dir: tuple[Pat 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 + from nemo_agents_plugin.container.template import render_nat_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)) + result = render_nat_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 @@ -1174,9 +1190,9 @@ class TestEndToEndPipeline: @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_nat_agent_image - from nemo_agents_plugin.container.template import render_dockerfile, render_dockerignore + from nemo_agents_plugin.container.template import render_dockerignore, render_nat_dockerfile - rendered = render_dockerfile( + rendered = render_nat_dockerfile( agent_config, None, nat_version="1.4.0", @@ -1263,14 +1279,14 @@ def test_validation_blocks_bad_config(self, mock_build: MagicMock, tmp_path: Pat @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 + from nemo_agents_plugin.container.template import render_nat_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( + rendered = render_nat_dockerfile( agent_config, None, nat_version="7.0.0", @@ -1470,6 +1486,74 @@ def test_default_runs_build_without_publish(self, package_cli, agent_config: Pat assert mock_build.call_args.kwargs["tag"] == "my-agent:1.0" mock_push.assert_not_called() + def test_shared_package_env_vars_are_forwarded( + self, + package_cli, + agent_config: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Canonical shared environment variables reach the selected builder.""" + app, runner = package_cli + monkeypatch.setenv("NEMO_AGENTS_BASE_IMAGE_URL", "env/image") + monkeypatch.setenv("NEMO_AGENTS_BASE_IMAGE_TAG", "env-tag") + monkeypatch.setenv("NEMO_AGENTS_PYTHON_VERSION", "3.12") + monkeypatch.setenv("NEMO_AGENTS_UV_VERSION", "0.9.0") + + with patch("nemo_agents_plugin.container.builder.build_nat_agent_image") as mock_build: + mock_build.return_value = "my-agent:dev" + result = runner.invoke( + app, + ["package", "--agent", str(agent_config), "--nat-version", "1.8.0"], + ) + + assert result.exit_code == 0, result.stdout + kwargs = mock_build.call_args.kwargs + assert kwargs["base_image_url"] == "env/image" + assert kwargs["base_image_tag"] == "env-tag" + assert kwargs["python_version"] == "3.12" + assert kwargs["uv_version"] == "0.9.0" + + def test_shared_package_flags_override_environment( + self, + package_cli, + agent_config: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Explicit shared package flags take precedence over environment values.""" + app, runner = package_cli + monkeypatch.setenv("NEMO_AGENTS_BASE_IMAGE_URL", "env/image") + monkeypatch.setenv("NEMO_AGENTS_BASE_IMAGE_TAG", "env-tag") + monkeypatch.setenv("NEMO_AGENTS_PYTHON_VERSION", "3.11") + monkeypatch.setenv("NEMO_AGENTS_UV_VERSION", "0.8.0") + + with patch("nemo_agents_plugin.container.builder.build_nat_agent_image") as mock_build: + mock_build.return_value = "my-agent:dev" + result = runner.invoke( + app, + [ + "package", + "--agent", + str(agent_config), + "--nat-version", + "1.8.0", + "--base-image-url", + "flag/image", + "--base-image-tag", + "flag-tag", + "--python-version", + "3.13", + "--uv-version", + "0.10.0", + ], + ) + + assert result.exit_code == 0, result.stdout + kwargs = mock_build.call_args.kwargs + assert kwargs["base_image_url"] == "flag/image" + assert kwargs["base_image_tag"] == "flag-tag" + assert kwargs["python_version"] == "3.13" + assert kwargs["uv_version"] == "0.10.0" + def test_fabric_config_routes_only_to_fabric_builder(self, package_cli, tmp_path: Path) -> None: """Fabric configs select the Fabric builder without NAT-only arguments.""" app, runner = package_cli @@ -1500,6 +1584,75 @@ def test_fabric_config_routes_only_to_fabric_builder(self, package_cli, tmp_path assert mock_fabric_build.call_args.kwargs["tag"] == "fabric-agent:dev" assert "nat_version" not in mock_fabric_build.call_args.kwargs + def test_fabric_config_ignores_ambient_nat_version( + self, package_cli, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A shell-level NAT_VERSION must not affect Fabric packaging.""" + app, runner = package_cli + agent_config = tmp_path / "agent.yaml" + agent_config.write_text("config_format: nemo-agents-spec-v1\nname: fabric-agent\n") + monkeypatch.setenv("NAT_VERSION", "1.8.0") + + with ( + patch("nemo_agents_plugin.container.builder.build_fabric_agent_image") as mock_fabric_build, + patch("nemo_agents_plugin.container.builder.build_nat_agent_image") as mock_nat_build, + ): + mock_fabric_build.return_value = "fabric-agent:dev" + result = runner.invoke(app, ["package", "--agent", str(agent_config)]) + + assert result.exit_code == 0, result.stdout + mock_fabric_build.assert_called_once() + mock_nat_build.assert_not_called() + assert "nat_version" not in mock_fabric_build.call_args.kwargs + + def test_fabric_config_rejects_explicit_nat_version(self, package_cli, tmp_path: Path) -> None: + """An explicit --nat-version remains an error for Fabric packaging.""" + app, runner = package_cli + agent_config = tmp_path / "agent.yaml" + agent_config.write_text("config_format: nemo-agents-spec-v1\nname: fabric-agent\n") + + with ( + patch("nemo_agents_plugin.container.builder.build_fabric_agent_image") as mock_fabric_build, + patch("nemo_agents_plugin.container.builder.build_nat_agent_image") as mock_nat_build, + ): + result = runner.invoke( + app, + ["package", "--agent", str(agent_config), "--nat-version", "1.8.0"], + ) + + assert result.exit_code == 1 + assert "--nat-version is only valid for NAT workflow packaging" in (result.stderr or result.stdout) + mock_fabric_build.assert_not_called() + mock_nat_build.assert_not_called() + + def test_nat_config_resolves_nat_version_from_environment( + self, + package_cli, + agent_config: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """NAT packaging still resolves NAT_VERSION after CLI routing.""" + app, runner = package_cli + output = tmp_path / "Dockerfile" + monkeypatch.setenv("NAT_VERSION", "1.8.0") + + result = runner.invoke( + app, + [ + "package", + "--agent", + str(agent_config), + "--output", + str(output), + "--no-build", + ], + ) + + assert result.exit_code == 0, result.stdout + assert "ARG NAT_VERSION=1.8.0" in output.read_text() + assert "--nat-version not provided" not in ((result.stderr or "") + result.stdout) + 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 @@ -1648,13 +1801,13 @@ def test_label_values_are_escaped_against_dockerfile_injection(self, agent_confi ``RUN`` instruction). * ``\\`` in ``--agent-author`` (escape character collision). """ - from nemo_agents_plugin.container.template import _dockerfile_escape, render_dockerfile + from nemo_agents_plugin.container.template import _dockerfile_escape, render_nat_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( + rendered = render_nat_dockerfile( agent_config, nat_version="1.4.0", agent_version="1.0.0", @@ -1845,7 +1998,7 @@ def test_outside_pyproject_tree_fails_fast_instead_of_silently_breaking( 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 + from nemo_agents_plugin.container.template import render_nat_dockerfile elsewhere = tmp_path / "elsewhere" elsewhere.mkdir() @@ -1853,7 +2006,7 @@ def test_outside_pyproject_tree_fails_fast_instead_of_silently_breaking( 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") + render_nat_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. From c5b627a1f003554432336a86d58509f858dac42d Mon Sep 17 00:00:00 2001 From: Manjesh Mogallapalli Date: Sat, 1 Aug 2026 15:23:30 -0500 Subject: [PATCH 03/13] fabric dockerfile render + package install Signed-off-by: Manjesh Mogallapalli --- .../nemo-agents/src/nemo_agents_plugin/cli.py | 53 ++-- .../nemo_agents_plugin/container/template.py | 152 ++++++++- .../nemo-agents/tests/unit/test_container.py | 291 ++++++++++++++++++ 3 files changed, 473 insertions(+), 23 deletions(-) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/cli.py b/plugins/nemo-agents/src/nemo_agents_plugin/cli.py index 1c2ce40f75..0dc3f5f48e 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/cli.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/cli.py @@ -634,27 +634,42 @@ def _package_render_only( # before we get here; assert for the developer who deletes that guard. assert format == "docker", f"unreachable: format={format!r}" - if config_format == NEMO_AGENTS_SPEC_CONFIG_FORMAT: - typer.echo("Error: Fabric agent packaging is not implemented yet.", err=True) - raise typer.Exit(code=1) - - from nemo_agents_plugin.container.template import render_dockerignore, render_nat_dockerfile + from nemo_agents_plugin.container.template import ( + render_dockerignore, + render_fabric_dockerfile, + render_nat_dockerfile, + ) try: - content = render_nat_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, - sandbox_runtime=sandbox_runtime, - agent_version=agent_version, - agent_author=agent_author, - template_path=template, - ) + if config_format == NEMO_AGENTS_SPEC_CONFIG_FORMAT: + content = render_fabric_dockerfile( + agent_config, + pyproject, + base_image_url=base_image_url, + base_image_tag=base_image_tag, + python_version=python_version, + uv_version=uv_version, + allow_root=allow_root, + sandbox_runtime=sandbox_runtime, + agent_version=agent_version, + agent_author=agent_author, + template_path=template, + ) + else: + content = render_nat_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, + sandbox_runtime=sandbox_runtime, + 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) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/container/template.py b/plugins/nemo-agents/src/nemo_agents_plugin/container/template.py index 87c233a321..b74a43c1b2 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/container/template.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/container/template.py @@ -3,10 +3,10 @@ """Jinja2-based Dockerfile rendering primitives for packaged agents. -The current built-in template targets NAT agents. Shared render parameters -are kept separate from NAT-specific parameters so Fabric packaging can reuse -the image, project, sandbox, and metadata contract without inheriting NAT -runtime fields. +The built-in templates target NAT and Fabric agents. Shared render parameters +are kept separate from runtime-specific parameters so both packaging paths can +reuse the image, project, sandbox, and metadata contract without inheriting +fields owned by the other runtime. Two rendering modes are supported: @@ -89,6 +89,8 @@ def is_plugin_managed(path: Path) -> bool: "uv_version": "NEMO_AGENTS_UV_VERSION", } +_PINNED_NEMO_RELAY_CLI_VERSION = "0.6.0" + # -- Jinja2 template -------------------------------------------------------- NAT_DOCKERFILE_TEMPLATE = ( @@ -188,6 +190,99 @@ def is_plugin_managed(path: Path) -> bool: """ ) +FABRIC_DOCKERFILE_TEMPLATE = ( + f"""\ +{DOCKERFILE_SENTINEL} +""" + + """\ +ARG BASE_IMAGE_URL={{ base_image_url }} +ARG BASE_IMAGE_TAG={{ base_image_tag }} +ARG PYTHON_VERSION={{ python_version }} +FROM ${BASE_IMAGE_URL}:${BASE_IMAGE_TAG} +ARG PYTHON_VERSION + +COPY --from=ghcr.io/astral-sh/uv:{{ uv_version }} /uv /uvx /bin/ + +ENV PYTHONDONTWRITEBYTECODE=1 + +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{% if sandbox_apt_packages %} {{ sandbox_apt_packages }}{% endif %} && \\ + update-ca-certificates && \\ + rm -rf /var/lib/apt/lists/* + +# Claude and Codex Relay integration launches this external CLI. The installer +# verifies the checksum for the pinned release before placing it on the global +# runtime PATH. +RUN curl -fsSL https://raw.githubusercontent.com/NVIDIA/NeMo-Relay/main/install.sh -o /tmp/install-nemo-relay.sh && \\ + NEMO_RELAY_VERSION={{ pinned_nemo_relay_cli_version }} sh /tmp/install-nemo-relay.sh --install-dir /usr/local/bin && \\ + rm /tmp/install-nemo-relay.sh && \\ + nemo-relay --version + +ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt +ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt + +WORKDIR /workspace + +# Preserve the complete agent bundle so paths in agent.yaml continue to resolve +# relative to the packaged config directory. +COPY ./ /workspace +{% if has_pyproject %} +# Install the release-matched NeMo Agents runtime and the packaged project in +# one resolution so incompatible project constraints fail during image build. +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 --prerelease=allow "nemo-agents-plugin=={{ contract_version }}" . && \\ + chmod -R a+rX /opt/uv /workspace/.venv +{% else %} +# The plugin owns the supported Fabric adapter and harness dependency set. +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 --prerelease=allow "nemo-agents-plugin=={{ contract_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.contract-version="{{ contract_version | dockerfile_escape }}" + +ENV AGENT_CONFIG_PATH={{ config_file_path }} +ENV PORT=8000 +ENV PATH="/workspace/.venv/bin:$PATH" + +EXPOSE 8000 + +{% if sandbox_user_setup %} +# Sandbox-runtime compatibility ({{ sandbox_runtime }}). The supervisor resolves +# this user by name, so the uid is not load-bearing; --system keeps it out of the +# 1000/1001 range the agent user reclaims below. +RUN {{ sandbox_user_setup }} +{% endif %} +{% if not allow_root %} +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 python -m nemo_agents_plugin.fabric.server --agent-config \\\"$AGENT_CONFIG_PATH\\\" --host 0.0.0.0 --port \\\"$PORT\\\""] +""" +) + DOCKERIGNORE_TEMPLATE = f"""\ {DOCKERIGNORE_SENTINEL} .env @@ -248,6 +343,11 @@ class NatRenderParams(SharedRenderParams): nat_version: str = "" +@dataclass +class FabricRenderParams(SharedRenderParams): + """Resolved parameters specific to Fabric Dockerfile rendering.""" + + # -- Public API ------------------------------------------------------------- @@ -319,6 +419,7 @@ def _jinja_env() -> jinja2.Environment: undefined=jinja2.StrictUndefined, ) env.filters["dockerfile_escape"] = _dockerfile_escape + env.globals["pinned_nemo_relay_cli_version"] = _PINNED_NEMO_RELAY_CLI_VERSION return env @@ -496,6 +597,49 @@ def render_nat_dockerfile( return template.render(**_render_context(params)) +def render_fabric_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, + uv_version: str | None = None, + allow_root: bool = False, + sandbox_runtime: str | None = None, + 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 Fabric-backed agent.""" + shared = resolve_shared_render_params( + agent_config, + pyproject, + base_image_url=base_image_url, + base_image_tag=base_image_tag, + python_version=python_version, + uv_version=uv_version, + allow_root=allow_root, + sandbox_runtime=sandbox_runtime, + agent_version=agent_version, + agent_author=agent_author, + metadata=metadata, + ) + params = FabricRenderParams(**{f.name: getattr(shared, f.name) for f in fields(shared)}) + + if template_path: + 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 = FABRIC_DOCKERFILE_TEMPLATE + + template = _jinja_env().from_string(template_source) + return template.render(**_render_context(params)) + + def _get_contract_version() -> str: """Return the ``nemo-agents-plugin`` package version.""" from importlib.metadata import PackageNotFoundError, version diff --git a/plugins/nemo-agents/tests/unit/test_container.py b/plugins/nemo-agents/tests/unit/test_container.py index e3c50314ef..3b2c71a4d2 100644 --- a/plugins/nemo-agents/tests/unit/test_container.py +++ b/plugins/nemo-agents/tests/unit/test_container.py @@ -72,6 +72,19 @@ def test_nat_params_extend_shared_contract(self) -> None: assert not hasattr(shared, "nat_version") assert nat.nat_version == "1.8.0" + def test_fabric_params_extend_shared_contract(self) -> None: + from nemo_agents_plugin.container.template import FabricRenderParams, SharedRenderParams + + shared = SharedRenderParams() + fabric = FabricRenderParams() + + assert isinstance(fabric, SharedRenderParams) + assert not hasattr(shared, "nemo_agents_version") + assert not hasattr(shared, "relay_cli_version") + assert not hasattr(fabric, "nemo_agents_version") + assert not hasattr(fabric, "relay_cli_version") + assert not hasattr(fabric, "nat_version") + def test_config_only_mode(self, agent_config: Path) -> None: from nemo_agents_plugin.container.template import render_nat_dockerfile @@ -366,6 +379,182 @@ def test_external_template(self, agent_config: Path, tmp_path: Path) -> None: assert "echo 9.9.9" in result +class TestFabricDockerfileTemplate: + """Direct contract tests for the Fabric template before its renderer is wired.""" + + @staticmethod + def _render(**overrides: object) -> str: + from dataclasses import asdict + + from nemo_agents_plugin.container.template import ( + FABRIC_DOCKERFILE_TEMPLATE, + FabricRenderParams, + _jinja_env, + ) + + params = FabricRenderParams(contract_version="1.2.3", **overrides) + return _jinja_env().from_string(FABRIC_DOCKERFILE_TEMPLATE).render(**asdict(params)) + + def test_installs_pinned_relay_cli_globally(self) -> None: + from nemo_agents_plugin.container.template import _PINNED_NEMO_RELAY_CLI_VERSION + + result = self._render() + + assert f"NEMO_RELAY_VERSION={_PINNED_NEMO_RELAY_CLI_VERSION}" in result + assert "--install-dir /usr/local/bin" in result + assert "nemo-relay --version" in result + assert "ARG NEMO_RELAY" not in result + + def test_preserves_agent_bundle_and_config_path(self) -> None: + result = self._render(config_file_path="/workspace/configs/agent.yaml") + + assert "COPY ./ /workspace" in result + assert "ENV AGENT_CONFIG_PATH=/workspace/configs/agent.yaml" in result + assert "COPY agent.yaml" not in result + + def test_starts_fabric_server(self) -> None: + result = self._render() + + assert "ENV PORT=8000" in result + assert "EXPOSE 8000" in result + assert ( + 'ENTRYPOINT ["sh", "-c", "exec python -m nemo_agents_plugin.fabric.server ' + '--agent-config \\"$AGENT_CONFIG_PATH\\" --host 0.0.0.0 --port \\"$PORT\\""]' + in result + ) + + +class TestRenderFabricDockerfile: + """Tests for the public Fabric Dockerfile renderer.""" + + @staticmethod + def _write_config(directory: Path) -> Path: + agent_config = directory / "agent.yaml" + agent_config.write_text("config_format: nemo-agents-spec-v1\nname: fabric-agent\n") + return agent_config + + def test_config_only_mode(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.template import render_fabric_dockerfile + + agent_config = self._write_config(tmp_path) + + result = render_fabric_dockerfile(agent_config) + + assert 'uv pip install --prerelease=allow "nemo-agents-plugin==' in result + install_line = next(line for line in result.splitlines() if "uv pip install --prerelease=allow" in line) + assert '" .' not in install_line + assert "ENV AGENT_CONFIG_PATH=/workspace/agent.yaml" in result + assert "NAT_VERSION" not in result + + def test_project_mode_preserves_relative_config_path(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.template import render_fabric_dockerfile + + configs = tmp_path / "configs" + configs.mkdir() + agent_config = self._write_config(configs) + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "fabric-agent"\nversion = "1.0.0"\n') + + result = render_fabric_dockerfile(agent_config, pyproject) + + assert 'uv pip install --prerelease=allow "nemo-agents-plugin==' in result + install_line = next(line for line in result.splitlines() if "uv pip install --prerelease=allow" in line) + assert '" .' in install_line + assert "ENV AGENT_CONFIG_PATH=/workspace/configs/agent.yaml" in result + + def test_custom_template_uses_fabric_context(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.template import render_fabric_dockerfile + + agent_config = self._write_config(tmp_path) + custom = tmp_path / "fabric.dockerfile.j2" + custom.write_text("FROM {{ base_image_url }}:{{ base_image_tag }}\nENV CONFIG={{ config_file_path }}\n") + + result = render_fabric_dockerfile( + agent_config, + base_image_url="custom/image", + base_image_tag="custom-tag", + template_path=str(custom), + ) + + assert result == "FROM custom/image:custom-tag\nENV CONFIG=/workspace/agent.yaml\n" + + def test_shared_environment_and_explicit_overrides( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + from nemo_agents_plugin.container.template import render_fabric_dockerfile + + agent_config = self._write_config(tmp_path) + monkeypatch.setenv("NEMO_AGENTS_BASE_IMAGE_URL", "env/image") + monkeypatch.setenv("NEMO_AGENTS_BASE_IMAGE_TAG", "env-tag") + monkeypatch.setenv("NEMO_AGENTS_PYTHON_VERSION", "3.12") + monkeypatch.setenv("NEMO_AGENTS_UV_VERSION", "0.9.0") + monkeypatch.setenv("NAT_VERSION", "9.9.9") + + from_environment = render_fabric_dockerfile(agent_config) + explicit = render_fabric_dockerfile( + agent_config, + base_image_url="flag/image", + base_image_tag="flag-tag", + python_version="3.13", + uv_version="0.10.0", + ) + + assert "ARG BASE_IMAGE_URL=env/image" in from_environment + assert "ARG BASE_IMAGE_TAG=env-tag" in from_environment + assert "ARG PYTHON_VERSION=3.12" in from_environment + assert "ghcr.io/astral-sh/uv:0.9.0" in from_environment + assert "ARG BASE_IMAGE_URL=flag/image" in explicit + assert "ARG BASE_IMAGE_TAG=flag-tag" in explicit + assert "ARG PYTHON_VERSION=3.13" in explicit + assert "ghcr.io/astral-sh/uv:0.10.0" in explicit + assert "9.9.9" not in from_environment + explicit + + def test_allow_root_and_sandbox_profile(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.template import render_fabric_dockerfile + from nemo_platform_plugin.sandbox import SandboxImageProfile, SandboxUser + + agent_config = self._write_config(tmp_path) + profile = SandboxImageProfile( + name="openshell", + apt_packages=("iproute2", "nftables"), + users=(SandboxUser(name="sandbox", system=True, create_home=True),), + ) + + with patch( + "nemo_agents_plugin.container.sandbox.discover_sandbox_profiles", + return_value={"openshell": profile}, + ): + result = render_fabric_dockerfile(agent_config, sandbox_runtime="openshell", allow_root=True) + + assert "iproute2 nftables" in result + assert "groupadd --system sandbox" in result + assert "USER agent" not in result + assert "nemo_agents_plugin.fabric.server" in result + + def test_config_outside_project_context_is_rejected(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.template import render_fabric_dockerfile + + agent_dir = tmp_path / "agent" + project_dir = tmp_path / "project" + agent_dir.mkdir() + project_dir.mkdir() + agent_config = self._write_config(agent_dir) + pyproject = project_dir / "pyproject.toml" + pyproject.write_text('[project]\nname = "fabric-agent"\nversion = "1.0.0"\n') + + with pytest.raises(ValueError, match="outside the pyproject build context"): + render_fabric_dockerfile(agent_config, pyproject) + + def test_unreadable_custom_template_is_reported_as_value_error(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.template import render_fabric_dockerfile + + agent_config = self._write_config(tmp_path) + missing = tmp_path / "missing.dockerfile.j2" + + with pytest.raises(ValueError, match="failed to read --template file"): + render_fabric_dockerfile(agent_config, template_path=str(missing)) + + # --------------------------------------------------------------------------- # .dockerignore tests # --------------------------------------------------------------------------- @@ -1370,6 +1559,108 @@ def test_no_build_renders_dockerfile_and_ignore(self, package_cli, agent_config: mock_build.assert_not_called() mock_push.assert_not_called() + def test_fabric_no_build_renders_fabric_dockerfile_and_ignore(self, package_cli, tmp_path: Path) -> None: + """Fabric ``--no-build`` renders without invoking either image builder.""" + app, runner = package_cli + agent_config = tmp_path / "agent.yaml" + agent_config.write_text("config_format: nemo-agents-spec-v1\nname: fabric-agent\n") + output = tmp_path / "Dockerfile.fabric" + + with ( + patch("nemo_agents_plugin.container.builder.build_fabric_agent_image") as mock_fabric_build, + patch("nemo_agents_plugin.container.builder.build_nat_agent_image") as mock_nat_build, + patch("nemo_agents_plugin.container.publisher.docker_push") as mock_push, + ): + result = runner.invoke( + app, + [ + "package", + "--agent", + str(agent_config), + "--output", + str(output), + "--no-build", + ], + ) + + assert result.exit_code == 0, result.stdout + rendered = output.read_text() + assert "nemo_agents_plugin.fabric.server" in rendered + assert "ENV AGENT_CONFIG_PATH=/workspace/agent.yaml" in rendered + assert "NAT_VERSION" not in rendered + assert (tmp_path / ".dockerignore").exists() + assert "Dockerfile written to" in result.stdout + mock_fabric_build.assert_not_called() + mock_nat_build.assert_not_called() + mock_push.assert_not_called() + + def test_fabric_no_build_forwards_shared_flags(self, package_cli, tmp_path: Path) -> None: + """Fabric render-only mode honors shared CLI flags without NAT arguments.""" + app, runner = package_cli + agent_config = tmp_path / "agent.yaml" + agent_config.write_text("config_format: nemo-agents-spec-v1\nname: fabric-agent\n") + output = tmp_path / "Dockerfile.fabric" + + result = runner.invoke( + app, + [ + "package", + "--agent", + str(agent_config), + "--output", + str(output), + "--base-image-url", + "custom/image", + "--base-image-tag", + "custom-tag", + "--python-version", + "3.12", + "--uv-version", + "0.9.0", + "--allow-root", + "--no-build", + ], + ) + + assert result.exit_code == 0, result.stdout + rendered = output.read_text() + assert "ARG BASE_IMAGE_URL=custom/image" in rendered + assert "ARG BASE_IMAGE_TAG=custom-tag" in rendered + assert "ARG PYTHON_VERSION=3.12" in rendered + assert "ghcr.io/astral-sh/uv:0.9.0" in rendered + assert "USER agent" not in rendered + assert "NAT_VERSION" not in rendered + + def test_fabric_no_build_project_mode_uses_project_root(self, package_cli, tmp_path: Path) -> None: + app, runner = package_cli + configs = tmp_path / "configs" + configs.mkdir() + agent_config = configs / "agent.yaml" + agent_config.write_text("config_format: nemo-agents-spec-v1\nname: fabric-agent\n") + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "fabric-agent"\nversion = "1.0.0"\n') + + result = runner.invoke( + app, + [ + "package", + "--agent", + str(agent_config), + "--pyproject", + str(pyproject), + "--no-build", + ], + ) + + assert result.exit_code == 0, result.stdout + output = tmp_path / "Dockerfile" + assert output.exists() + rendered = output.read_text() + assert "ENV AGENT_CONFIG_PATH=/workspace/configs/agent.yaml" in rendered + install_line = next(line for line in rendered.splitlines() if "uv pip install --prerelease=allow" in line) + assert '" .' in install_line + assert not (configs / "Dockerfile").exists() + def test_no_build_project_mode_writes_dockerfile_next_to_pyproject( self, package_cli, project_dir: "tuple[Path, Path]" ) -> None: From a29c8f67a89f4f34d86f0f1e419f16c8b0b9f74b Mon Sep 17 00:00:00 2001 From: Manjesh Mogallapalli Date: Sat, 1 Aug 2026 15:29:48 -0500 Subject: [PATCH 04/13] lint fix Signed-off-by: Manjesh Mogallapalli --- plugins/nemo-agents/src/nemo_agents_plugin/cli.py | 12 +++--------- plugins/nemo-agents/tests/unit/test_container.py | 7 ++----- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/cli.py b/plugins/nemo-agents/src/nemo_agents_plugin/cli.py index 0dc3f5f48e..976d65378c 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/cli.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/cli.py @@ -364,15 +364,9 @@ def package( "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="NEMO_AGENTS_BASE_IMAGE_URL" - ), - base_image_tag: Optional[str] = typer.Option( - None, "--base-image-tag", envvar="NEMO_AGENTS_BASE_IMAGE_TAG" - ), - python_version: Optional[str] = typer.Option( - None, "--python-version", envvar="NEMO_AGENTS_PYTHON_VERSION" - ), + base_image_url: Optional[str] = typer.Option(None, "--base-image-url", envvar="NEMO_AGENTS_BASE_IMAGE_URL"), + base_image_tag: Optional[str] = typer.Option(None, "--base-image-tag", envvar="NEMO_AGENTS_BASE_IMAGE_TAG"), + python_version: Optional[str] = typer.Option(None, "--python-version", envvar="NEMO_AGENTS_PYTHON_VERSION"), nat_version: Optional[str] = typer.Option( None, "--nat-version", diff --git a/plugins/nemo-agents/tests/unit/test_container.py b/plugins/nemo-agents/tests/unit/test_container.py index 3b2c71a4d2..50ae84f989 100644 --- a/plugins/nemo-agents/tests/unit/test_container.py +++ b/plugins/nemo-agents/tests/unit/test_container.py @@ -419,8 +419,7 @@ def test_starts_fabric_server(self) -> None: assert "EXPOSE 8000" in result assert ( 'ENTRYPOINT ["sh", "-c", "exec python -m nemo_agents_plugin.fabric.server ' - '--agent-config \\"$AGENT_CONFIG_PATH\\" --host 0.0.0.0 --port \\"$PORT\\""]' - in result + '--agent-config \\"$AGENT_CONFIG_PATH\\" --host 0.0.0.0 --port \\"$PORT\\""]' in result ) @@ -478,9 +477,7 @@ def test_custom_template_uses_fabric_context(self, tmp_path: Path) -> None: assert result == "FROM custom/image:custom-tag\nENV CONFIG=/workspace/agent.yaml\n" - def test_shared_environment_and_explicit_overrides( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_shared_environment_and_explicit_overrides(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: from nemo_agents_plugin.container.template import render_fabric_dockerfile agent_config = self._write_config(tmp_path) From deba833511924dcfb6483cd4dbd80f15a44d2d92 Mon Sep 17 00:00:00 2001 From: Manjesh Mogallapalli Date: Sat, 1 Aug 2026 15:48:57 -0500 Subject: [PATCH 05/13] validate fabric configs for packaging Signed-off-by: Manjesh Mogallapalli --- .../nemo-agents/src/nemo_agents_plugin/cli.py | 2 +- .../nemo_agents_plugin/container/builder.py | 14 +- .../container/fabric_validator.py | 115 +++++++ .../nemo_agents_plugin/fabric/validation.py | 35 ++- .../nemo-agents/tests/unit/test_container.py | 34 +++ .../unit/test_fabric_package_validation.py | 281 ++++++++++++++++++ .../tests/unit/test_fabric_validation.py | 23 ++ 7 files changed, 496 insertions(+), 8 deletions(-) create mode 100644 plugins/nemo-agents/src/nemo_agents_plugin/container/fabric_validator.py create mode 100644 plugins/nemo-agents/tests/unit/test_fabric_package_validation.py diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/cli.py b/plugins/nemo-agents/src/nemo_agents_plugin/cli.py index 976d65378c..afbfcd9c38 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/cli.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/cli.py @@ -393,7 +393,7 @@ def package( 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." + False, "--skip-validation", help="Bypass agent config validation 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."), diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py b/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py index f7bc229f5b..50ce7667d6 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py @@ -274,6 +274,18 @@ def build_fabric_agent_image( packaging can grow without inheriting NAT-specific args such as ``nat_version`` or ``NAT_CONFIG_FILE``. """ + if pyproject is not None and pyproject.exists(): + context_dir = pyproject.resolve().parent + else: + context_dir = agent_config.resolve().parent + + if not skip_validation: + import asyncio + + from nemo_agents_plugin.container.fabric_validator import validate_fabric_agent_package + + asyncio.run(validate_fabric_agent_package(agent_config, context_dir=context_dir)) + del ( agent_config, pyproject, @@ -288,10 +300,10 @@ def build_fabric_agent_image( agent_version, agent_author, template_path, - skip_validation, generate_ignore, platforms, push, + context_dir, ) raise ValueError("Fabric agent packaging is not implemented yet.") diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/container/fabric_validator.py b/plugins/nemo-agents/src/nemo_agents_plugin/container/fabric_validator.py new file mode 100644 index 0000000000..77b669e934 --- /dev/null +++ b/plugins/nemo-agents/src/nemo_agents_plugin/container/fabric_validator.py @@ -0,0 +1,115 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Build-context validation for packaged Fabric-backed agents.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from nemo_agents_plugin.agent_config import AgentConfig, AgentConfigLoadError, load_agent_config +from nemo_agents_plugin.fabric.translator import FabricTranslationError, translate_agent_config +from nemo_agents_plugin.fabric.validation import FabricValidationError, plan_fabric_config + +if TYPE_CHECKING: + from nemo_fabric import FabricConfig + + +class FabricPackageValidationError(ValueError): + """Raised when a Fabric agent cannot be validated for packaging.""" + + +class FabricPackageArtifactError(FabricPackageValidationError): + """Raised when a referenced Fabric artifact cannot be packaged.""" + + +@dataclass(frozen=True, slots=True) +class FabricPackageValidationResult: + """Loaded, translated, and planned Fabric package configuration.""" + + agent_config: AgentConfig + fabric_config: FabricConfig + plan: Any + + +async def validate_fabric_agent_package( + agent_config_path: Path, + *, + context_dir: Path, + fabric: Any | None = None, +) -> FabricPackageValidationResult: + """Load, translate, plan, and validate artifacts for a Fabric package.""" + try: + agent_config = load_agent_config(agent_config_path) + fabric_config = translate_agent_config(agent_config) + plan = await plan_fabric_config( + fabric_config, + base_dir=agent_config_path.resolve().parent, + fabric=fabric, + ) + except (AgentConfigLoadError, FabricTranslationError, FabricValidationError) as error: + raise FabricPackageValidationError(f"Fabric package validation failed: {error}") from error + + validate_fabric_package_artifacts( + agent_config, + agent_config_path=agent_config_path, + context_dir=context_dir, + ) + return FabricPackageValidationResult( + agent_config=agent_config, + fabric_config=fabric_config, + plan=plan, + ) + + +def validate_fabric_package_artifacts( + config: AgentConfig, + *, + agent_config_path: Path, + context_dir: Path, +) -> None: + """Validate that config-referenced inputs will exist in the built image.""" + resolved_context = context_dir.resolve() + resolved_config = agent_config_path.resolve() + errors: list[str] = [] + + if not resolved_config.is_relative_to(resolved_context): + errors.append(f"agent config {agent_config_path} is outside Docker build context {resolved_context}") + + for configured_path in config.skills.paths if config.skills is not None else (): + skill_path = Path(configured_path) + if skill_path.is_absolute(): + errors.append( + f"skills.paths entry {configured_path!r} must be relative to agent.yaml " + "so it remains valid under /workspace" + ) + continue + + resolved_skill = (resolved_config.parent / skill_path).resolve() + if not resolved_skill.is_relative_to(resolved_context): + errors.append( + f"skills.paths entry {configured_path!r} resolves outside Docker build context {resolved_context}" + ) + continue + if not resolved_skill.exists(): + errors.append(f"skills.paths entry {configured_path!r} does not exist at {resolved_skill}") + continue + if not resolved_skill.is_dir(): + errors.append(f"skills.paths entry {configured_path!r} must reference a directory: {resolved_skill}") + continue + + skill_manifest = resolved_skill / "SKILL.md" + if not skill_manifest.is_file(): + errors.append(f"skills.paths entry {configured_path!r} does not contain SKILL.md: {resolved_skill}") + continue + try: + with skill_manifest.open("rb") as manifest: + manifest.read(1) + except OSError as error: + errors.append(f"skills.paths entry {configured_path!r} contains an unreadable SKILL.md: {error}") + + if errors: + details = "\n".join(f" - {error}" for error in errors) + raise FabricPackageArtifactError(f"Fabric package artifact validation failed:\n{details}") diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/validation.py b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/validation.py index 8fd5eb9a25..291225f224 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/validation.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/validation.py @@ -81,19 +81,42 @@ async def validate_fabric_config( base_dir: Path | str, fabric: Any | None = None, ) -> FabricValidationResult: - """Run Fabric plan and doctor for a translated FabricConfig. + """Run Fabric plan and doctor for a translated FabricConfig.""" + fabric_client = fabric or Fabric() + plan = await plan_fabric_config(fabric_config, base_dir=base_dir, fabric=fabric_client) + doctor_report = await doctor_fabric_config(fabric_config, base_dir=base_dir, fabric=fabric_client) + return FabricValidationResult(plan=plan, doctor_report=doctor_report) - This validates the selected harness and environment without invoking the - agent. Fabric is a required dependency of the ``nemo-agents`` plugin. - """ +async def plan_fabric_config( + fabric_config: FabricConfig, + *, + base_dir: Path | str, + fabric: Any | None = None, +) -> Any: + """Plan a translated Fabric config without running environment preflight. + + Packaging uses this narrower operation because the producer host does not + necessarily contain the harness binaries or Relay CLI installed by the + rendered image. + """ fabric_client = fabric or Fabric() try: - plan = await asyncio.to_thread(fabric_client.plan, fabric_config, base_dir=base_dir) + return await asyncio.to_thread(fabric_client.plan, fabric_config, base_dir=base_dir) except FabricConfigError as error: raise FabricValidationError(f"Fabric plan failed: {error}") from error + +async def doctor_fabric_config( + fabric_config: FabricConfig, + *, + base_dir: Path | str, + fabric: Any | None = None, +) -> Any: + """Run Fabric environment preflight and require a passing report.""" + fabric_client = fabric or Fabric() + try: doctor_report = await asyncio.wait_for( fabric_client.doctor(fabric_config, base_dir=base_dir), @@ -105,7 +128,7 @@ async def validate_fabric_config( raise FabricValidationError(f"Fabric doctor failed: {error}") from error _ensure_doctor_passed(_to_mapping(doctor_report)) - return FabricValidationResult(plan=plan, doctor_report=doctor_report) + return doctor_report def _coerce_agent_config(config: AgentConfig | Mapping[str, Any]) -> AgentConfig: diff --git a/plugins/nemo-agents/tests/unit/test_container.py b/plugins/nemo-agents/tests/unit/test_container.py index 50ae84f989..4e09bcb4f9 100644 --- a/plugins/nemo-agents/tests/unit/test_container.py +++ b/plugins/nemo-agents/tests/unit/test_container.py @@ -1872,6 +1872,40 @@ def test_fabric_config_routes_only_to_fabric_builder(self, package_cli, tmp_path assert mock_fabric_build.call_args.kwargs["tag"] == "fabric-agent:dev" assert "nat_version" not in mock_fabric_build.call_args.kwargs + def test_fabric_validation_error_is_reported_cleanly(self, package_cli, tmp_path: Path) -> None: + """Fabric package validation failures are presented as CLI errors.""" + from nemo_agents_plugin.container.fabric_validator import FabricPackageValidationError + + app, runner = package_cli + agent_config = tmp_path / "agent.yaml" + agent_config.write_text("config_format: nemo-agents-spec-v1\nname: fabric-agent\n") + + with patch("nemo_agents_plugin.container.builder.build_fabric_agent_image") as mock_fabric_build: + mock_fabric_build.side_effect = FabricPackageValidationError( + "Fabric package validation failed: invalid harness settings" + ) + result = runner.invoke(app, ["package", "--agent", str(agent_config)]) + + assert result.exit_code == 1 + assert "Error: Fabric package validation failed: invalid harness settings" in (result.stderr or result.stdout) + assert result.exception is not None + + def test_fabric_skip_validation_is_forwarded(self, package_cli, tmp_path: Path) -> None: + """The shared skip flag reaches the Fabric builder.""" + app, runner = package_cli + agent_config = tmp_path / "agent.yaml" + agent_config.write_text("config_format: nemo-agents-spec-v1\nname: fabric-agent\n") + + with patch("nemo_agents_plugin.container.builder.build_fabric_agent_image") as mock_fabric_build: + mock_fabric_build.return_value = "fabric-agent:dev" + result = runner.invoke( + app, + ["package", "--agent", str(agent_config), "--skip-validation"], + ) + + assert result.exit_code == 0, result.stdout + assert mock_fabric_build.call_args.kwargs["skip_validation"] is True + def test_fabric_config_ignores_ambient_nat_version( self, package_cli, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/plugins/nemo-agents/tests/unit/test_fabric_package_validation.py b/plugins/nemo-agents/tests/unit/test_fabric_package_validation.py new file mode 100644 index 0000000000..ac11c9f13c --- /dev/null +++ b/plugins/nemo-agents/tests/unit/test_fabric_package_validation.py @@ -0,0 +1,281 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for Fabric package artifact validation.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import nemo_agents_plugin.container.fabric_validator as fabric_validator +import pytest +from nemo_agents_plugin.agent_config import AgentConfig +from nemo_agents_plugin.container.fabric_validator import ( + FabricPackageArtifactError, + FabricPackageValidationError, + validate_fabric_agent_package, + validate_fabric_package_artifacts, +) + + +def _agent_config(*skill_paths: str) -> AgentConfig: + return AgentConfig.model_validate( + { + "config_format": "nemo-agents-spec-v1", + "name": "packaged-agent", + "default_harness": "codex", + "harnesses": {"codex": {"kind": "codex"}}, + "skills": {"paths": list(skill_paths)}, + } + ) + + +def _agent_config_path(context_dir: Path, relative_path: str = "agent.yaml") -> Path: + path = context_dir / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("config_format: nemo-agents-spec-v1\n") + return path + + +def _skill(directory: Path) -> Path: + directory.mkdir(parents=True) + (directory / "SKILL.md").write_text("# Packaged skill\n") + return directory + + +def _write_package_config(path: Path, *skill_paths: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(_agent_config(*skill_paths).model_dump_json()) + return path + + +@pytest.mark.asyncio +class TestValidateFabricAgentPackage: + async def test_loads_translates_plans_and_validates_artifacts( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + agent_config_path = _write_package_config(tmp_path / "configs" / "agent.yaml", "../skills/review") + _skill(tmp_path / "skills" / "review") + translated_config = object() + calls: dict[str, Any] = {} + + def _translate(config: AgentConfig) -> object: + calls["translated_agent_config"] = config + return translated_config + + async def _plan(config: object, *, base_dir: Path, fabric: Any | None = None) -> object: + calls["planned_config"] = config + calls["base_dir"] = base_dir + calls["fabric"] = fabric + return {"plan": "ok"} + + monkeypatch.setattr(fabric_validator, "translate_agent_config", _translate) + monkeypatch.setattr(fabric_validator, "plan_fabric_config", _plan) + + result = await validate_fabric_agent_package( + agent_config_path, + context_dir=tmp_path, + fabric="fabric-client", + ) + + assert result.agent_config is calls["translated_agent_config"] + assert result.fabric_config is translated_config + assert result.plan == {"plan": "ok"} + assert calls["planned_config"] is translated_config + assert calls["base_dir"] == agent_config_path.parent.resolve() + assert calls["fabric"] == "fabric-client" + + async def test_runs_plan_without_doctor(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + agent_config_path = _write_package_config(tmp_path / "agent.yaml") + translated_config = object() + + class _PlanOnlyFabric: + def __init__(self) -> None: + self.plan_calls: list[tuple[object, Path]] = [] + + def plan(self, config: object, *, base_dir: Path) -> object: + self.plan_calls.append((config, base_dir)) + return {"plan": "ok"} + + async def doctor(self, config: object, *, base_dir: Path) -> object: + raise AssertionError(f"doctor must not run for package validation: {config}, {base_dir}") + + fabric = _PlanOnlyFabric() + monkeypatch.setattr(fabric_validator, "translate_agent_config", lambda config: translated_config) + + result = await validate_fabric_agent_package( + agent_config_path, + context_dir=tmp_path, + fabric=fabric, + ) + + assert result.plan == {"plan": "ok"} + assert fabric.plan_calls == [(translated_config, agent_config_path.parent.resolve())] + + async def test_wraps_schema_error(self, tmp_path: Path) -> None: + invalid_config = tmp_path / "invalid.yaml" + invalid_config.write_text("name: missing-required-fields\n") + + with pytest.raises(FabricPackageValidationError, match="Invalid agent config"): + await validate_fabric_agent_package(invalid_config, context_dir=tmp_path) + + async def test_wraps_translation_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + valid_config = _write_package_config(tmp_path / "agent.yaml") + + def _translation_failure(config: AgentConfig) -> object: + del config + raise fabric_validator.FabricTranslationError("unsupported harness") + + monkeypatch.setattr(fabric_validator, "translate_agent_config", _translation_failure) + with pytest.raises(FabricPackageValidationError, match="unsupported harness"): + await validate_fabric_agent_package(valid_config, context_dir=tmp_path) + + async def test_wraps_plan_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + valid_config = _write_package_config(tmp_path / "agent.yaml") + monkeypatch.setattr(fabric_validator, "translate_agent_config", lambda config: object()) + + async def _plan_failure(config: object, *, base_dir: Path, fabric: Any | None = None) -> object: + del config, base_dir, fabric + raise fabric_validator.FabricValidationError("Fabric plan failed: invalid config") + + monkeypatch.setattr(fabric_validator, "plan_fabric_config", _plan_failure) + with pytest.raises(FabricPackageValidationError, match="Fabric plan failed: invalid config"): + await validate_fabric_agent_package(valid_config, context_dir=tmp_path) + + async def test_surfaces_artifact_validation_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + agent_config_path = _write_package_config(tmp_path / "agent.yaml", "skills/missing") + monkeypatch.setattr(fabric_validator, "translate_agent_config", lambda config: object()) + + async def _plan(config: object, *, base_dir: Path, fabric: Any | None = None) -> object: + del config, base_dir, fabric + return {"plan": "ok"} + + monkeypatch.setattr(fabric_validator, "plan_fabric_config", _plan) + + with pytest.raises(FabricPackageArtifactError, match="skills/missing"): + await validate_fabric_agent_package(agent_config_path, context_dir=tmp_path) + + +class TestFabricBuilderValidationHook: + def test_validates_with_selected_build_context(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_agents_plugin.container.builder import build_fabric_agent_image + + agent_config_path = _write_package_config(tmp_path / "configs" / "agent.yaml") + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "fabric-agent"\nversion = "1.0.0"\n') + calls: list[tuple[Path, Path]] = [] + + async def _validate(agent_config: Path, *, context_dir: Path) -> object: + calls.append((agent_config, context_dir)) + return object() + + monkeypatch.setattr(fabric_validator, "validate_fabric_agent_package", _validate) + + with pytest.raises(ValueError, match="not implemented yet"): + build_fabric_agent_image(agent_config_path, pyproject=pyproject) + + assert calls == [(agent_config_path, tmp_path.resolve())] + + def test_skip_validation_bypasses_hook(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_agents_plugin.container.builder import build_fabric_agent_image + + agent_config_path = _write_package_config(tmp_path / "agent.yaml") + + async def _unexpected_validation(agent_config: Path, *, context_dir: Path) -> object: + raise AssertionError(f"unexpected validation for {agent_config} in {context_dir}") + + monkeypatch.setattr(fabric_validator, "validate_fabric_agent_package", _unexpected_validation) + + with pytest.raises(ValueError, match="not implemented yet"): + build_fabric_agent_image(agent_config_path, skip_validation=True) + + +class TestValidateFabricPackageArtifacts: + def test_accepts_no_skills(self, tmp_path: Path) -> None: + agent_config_path = _agent_config_path(tmp_path) + + validate_fabric_package_artifacts( + _agent_config(), + agent_config_path=agent_config_path, + context_dir=tmp_path, + ) + + def test_accepts_skill_relative_to_nested_agent_config(self, tmp_path: Path) -> None: + agent_config_path = _agent_config_path(tmp_path, "configs/agent.yaml") + _skill(tmp_path / "skills" / "review") + + validate_fabric_package_artifacts( + _agent_config("../skills/review"), + agent_config_path=agent_config_path, + context_dir=tmp_path, + ) + + def test_rejects_absolute_skill_path(self, tmp_path: Path) -> None: + agent_config_path = _agent_config_path(tmp_path) + absolute_skill = _skill(tmp_path / "skills" / "review") + + with pytest.raises(FabricPackageArtifactError, match="must be relative to agent.yaml"): + validate_fabric_package_artifacts( + _agent_config(str(absolute_skill)), + agent_config_path=agent_config_path, + context_dir=tmp_path, + ) + + def test_rejects_missing_skill_and_manifest(self, tmp_path: Path) -> None: + agent_config_path = _agent_config_path(tmp_path) + (tmp_path / "skills" / "empty").mkdir(parents=True) + + with pytest.raises(FabricPackageArtifactError) as error_info: + validate_fabric_package_artifacts( + _agent_config("skills/missing", "skills/empty"), + agent_config_path=agent_config_path, + context_dir=tmp_path, + ) + + message = str(error_info.value) + assert "skills/missing" in message + assert "does not exist" in message + assert "skills/empty" in message + assert "does not contain SKILL.md" in message + + def test_rejects_skill_outside_context(self, tmp_path: Path) -> None: + context_dir = tmp_path / "project" + context_dir.mkdir() + agent_config_path = _agent_config_path(context_dir) + _skill(tmp_path / "shared-skill") + + with pytest.raises(FabricPackageArtifactError, match="resolves outside Docker build context"): + validate_fabric_package_artifacts( + _agent_config("../shared-skill"), + agent_config_path=agent_config_path, + context_dir=context_dir, + ) + + def test_rejects_symlink_that_escapes_context(self, tmp_path: Path) -> None: + context_dir = tmp_path / "project" + context_dir.mkdir() + agent_config_path = _agent_config_path(context_dir) + external_skill = _skill(tmp_path / "external-skill") + skills_dir = context_dir / "skills" + skills_dir.mkdir() + (skills_dir / "external").symlink_to(external_skill, target_is_directory=True) + + with pytest.raises(FabricPackageArtifactError, match="resolves outside Docker build context"): + validate_fabric_package_artifacts( + _agent_config("skills/external"), + agent_config_path=agent_config_path, + context_dir=context_dir, + ) + + def test_rejects_agent_config_outside_context(self, tmp_path: Path) -> None: + context_dir = tmp_path / "project" + context_dir.mkdir() + agent_config_path = _agent_config_path(tmp_path, "agent.yaml") + + with pytest.raises(FabricPackageArtifactError, match="agent config .* is outside Docker build context"): + validate_fabric_package_artifacts( + _agent_config(), + agent_config_path=agent_config_path, + context_dir=context_dir, + ) diff --git a/plugins/nemo-agents/tests/unit/test_fabric_validation.py b/plugins/nemo-agents/tests/unit/test_fabric_validation.py index 9d056b55ab..f303e5036b 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_validation.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_validation.py @@ -16,6 +16,8 @@ from nemo_agents_plugin.fabric.validation import ( FabricPreflightError, FabricValidationError, + doctor_fabric_config, + plan_fabric_config, validate_fabric_config, validate_platform_agent_config, ) @@ -116,6 +118,27 @@ def _example_platform_config() -> dict[str, Any]: @pytest.mark.asyncio class TestValidateFabricConfig: + async def test_plan_only_does_not_run_doctor(self, fake_fabric_client: None) -> None: + fabric_config = object() + fabric = _FakeFabric(plan={"plan": "ok"}) + + result = await plan_fabric_config(fabric_config, base_dir=Path("/tmp/agent"), fabric=fabric) + + assert result == {"plan": "ok"} + assert fabric.plan_calls == [{"fabric_config": fabric_config, "base_dir": Path("/tmp/agent")}] + assert fabric.doctor_calls == [] + + async def test_doctor_only_does_not_run_plan(self, fake_fabric_client: None) -> None: + fabric_config = object() + doctor_report = _FakeDoctorReport({"status": "pass", "checks": []}) + fabric = _FakeFabric(doctor_report=doctor_report) + + result = await doctor_fabric_config(fabric_config, base_dir=Path("/tmp/agent"), fabric=fabric) + + assert result is doctor_report + assert fabric.plan_calls == [] + assert fabric.doctor_calls == [{"fabric_config": fabric_config, "base_dir": Path("/tmp/agent")}] + async def test_returns_plan_and_doctor_report(self, fake_fabric_client: None) -> None: fabric_config = object() doctor_report = _FakeDoctorReport({"status": "pass", "checks": [{"name": "adapter", "status": "pass"}]}) From c4e80f3de2b4378527940ad004806585d6d17925 Mon Sep 17 00:00:00 2001 From: Manjesh Mogallapalli Date: Sat, 1 Aug 2026 16:13:01 -0500 Subject: [PATCH 06/13] complete build_fabric_agent_image method Signed-off-by: Manjesh Mogallapalli --- .../nemo_agents_plugin/container/builder.py | 95 ++++- .../unit/test_fabric_package_validation.py | 375 +++++++++++++++++- 2 files changed, 449 insertions(+), 21 deletions(-) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py b/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py index 50ce7667d6..08bc6bdab0 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py @@ -286,26 +286,87 @@ def build_fabric_agent_image( asyncio.run(validate_fabric_agent_package(agent_config, context_dir=context_dir)) - del ( + from nemo_agents_plugin.container.template import render_dockerignore, render_fabric_dockerfile, resolve_value + + resolved_base_url = resolve_value("base_image_url", base_image_url) + resolved_base_tag = resolve_value("base_image_tag", base_image_tag) + resolved_python = resolve_value("python_version", python_version) + resolved_uv = resolve_value("uv_version", uv_version) + + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + build_env_for_id = { + "base_image_url": resolved_base_url, + "base_image_tag": resolved_base_tag, + "python_version": resolved_python, + "uv_version": resolved_uv, + } + 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 = { + "BASE_IMAGE_URL": resolved_base_url, + "BASE_IMAGE_TAG": resolved_base_tag, + "PYTHON_VERSION": resolved_python, + } + + 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_fabric_dockerfile( agent_config, pyproject, - dockerfile, - tag, - base_image_url, - base_image_tag, - python_version, - uv_version, - allow_root, - sandbox_runtime, - agent_version, - agent_author, - template_path, - generate_ignore, - platforms, - push, - context_dir, + base_image_url=resolved_base_url, + base_image_tag=resolved_base_tag, + python_version=resolved_python, + uv_version=resolved_uv, + allow_root=allow_root, + sandbox_runtime=sandbox_runtime, + agent_version=agent_version, + agent_author=agent_author, + template_path=template_path, + metadata=meta, ) - raise ValueError("Fabric agent packaging is not implemented yet.") + + tmp_dockerfile = context_dir / "Dockerfile.generated" + if tmp_dockerfile.exists(): + raise typer.Exit(_emit_refusal_error(tmp_dockerfile)) + + ignore_file: Path | None = None + ignore_path = context_dir / ".dockerignore" + ignore_pre_existed = ignore_path.exists() + try: + tmp_dockerfile.write_text(content, encoding="utf-8") + + if generate_ignore: + 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 detect_agent_config_format(agent_config: Path) -> str: diff --git a/plugins/nemo-agents/tests/unit/test_fabric_package_validation.py b/plugins/nemo-agents/tests/unit/test_fabric_package_validation.py index ac11c9f13c..6fc84545a7 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_package_validation.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_package_validation.py @@ -10,6 +10,7 @@ import nemo_agents_plugin.container.fabric_validator as fabric_validator import pytest +import typer from nemo_agents_plugin.agent_config import AgentConfig from nemo_agents_plugin.container.fabric_validator import ( FabricPackageArtifactError, @@ -50,6 +51,21 @@ def _write_package_config(path: Path, *skill_paths: str) -> Path: return path +def _image_metadata() -> dict[str, str]: + return { + "agent_name": "fabric-agent", + "agent_id": "abc123", + "agent_version": "1.0.0", + "agent_author": "Agent Author", + "agent_framework": "unknown", + "build_timestamp": "2026-08-01T00:00:00+00:00", + "description": "Fabric agent", + "licenses": "Apache-2.0", + "revision": "revision", + "source": "https://example.com/fabric-agent.git", + } + + @pytest.mark.asyncio class TestValidateFabricAgentPackage: async def test_loads_translates_plans_and_validates_artifacts( @@ -158,6 +174,12 @@ async def _plan(config: object, *, base_dir: Path, fabric: Any | None = None) -> class TestFabricBuilderValidationHook: + @pytest.fixture(autouse=True) + def _stub_docker_build(self, monkeypatch: pytest.MonkeyPatch) -> None: + import nemo_agents_plugin.container.builder as builder + + monkeypatch.setattr(builder, "docker_build", lambda **kwargs: str(kwargs["tag"])) + def test_validates_with_selected_build_context(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: from nemo_agents_plugin.container.builder import build_fabric_agent_image @@ -172,11 +194,28 @@ async def _validate(agent_config: Path, *, context_dir: Path) -> object: monkeypatch.setattr(fabric_validator, "validate_fabric_agent_package", _validate) - with pytest.raises(ValueError, match="not implemented yet"): - build_fabric_agent_image(agent_config_path, pyproject=pyproject) + build_fabric_agent_image(agent_config_path, pyproject=pyproject) assert calls == [(agent_config_path, tmp_path.resolve())] + def test_uses_agent_config_directory_without_pyproject( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + from nemo_agents_plugin.container.builder import build_fabric_agent_image + + agent_config_path = _write_package_config(tmp_path / "configs" / "agent.yaml") + calls: list[tuple[Path, Path]] = [] + + async def _validate(agent_config: Path, *, context_dir: Path) -> object: + calls.append((agent_config, context_dir)) + return object() + + monkeypatch.setattr(fabric_validator, "validate_fabric_agent_package", _validate) + + build_fabric_agent_image(agent_config_path) + + assert calls == [(agent_config_path, agent_config_path.parent.resolve())] + def test_skip_validation_bypasses_hook(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: from nemo_agents_plugin.container.builder import build_fabric_agent_image @@ -187,8 +226,336 @@ async def _unexpected_validation(agent_config: Path, *, context_dir: Path) -> ob monkeypatch.setattr(fabric_validator, "validate_fabric_agent_package", _unexpected_validation) - with pytest.raises(ValueError, match="not implemented yet"): - build_fabric_agent_image(agent_config_path, skip_validation=True) + build_fabric_agent_image(agent_config_path, skip_validation=True) + + def test_resolves_shared_build_settings(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_agents_plugin.container import metadata, template + from nemo_agents_plugin.container.builder import build_fabric_agent_image + + monkeypatch.setattr(metadata, "extract_agent_metadata", lambda *args, **kwargs: _image_metadata()) + + agent_config_path = _write_package_config(tmp_path / "agent.yaml") + calls: list[tuple[str, str | None]] = [] + + def _resolve(name: str, explicit: str | None = None) -> str: + calls.append((name, explicit)) + return f"resolved-{name}" + + monkeypatch.setattr(template, "resolve_value", _resolve) + monkeypatch.setattr(template, "render_fabric_dockerfile", lambda *args, **kwargs: "FROM scratch\n") + + build_fabric_agent_image( + agent_config_path, + tag="fabric-agent:test", + base_image_url="registry.example/base", + base_image_tag="release", + python_version="3.13", + uv_version="0.8.15", + skip_validation=True, + ) + + assert calls == [ + ("base_image_url", "registry.example/base"), + ("base_image_tag", "release"), + ("python_version", "3.13"), + ("uv_version", "0.8.15"), + ] + + def test_extracts_metadata_and_derives_default_tag(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import nemo_agents_plugin.container.builder as builder + import nemo_agents_plugin.container.metadata as metadata + + agent_config_path = _write_package_config(tmp_path / "agent.yaml") + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "fabric-agent"\nversion = "1.0.0"\n') + extracted_meta = _image_metadata() + metadata_calls: list[dict[str, object]] = [] + tag_calls: list[dict[str, str]] = [] + + def _extract( + agent_config: Path, + project: Path | None, + **kwargs: object, + ) -> dict[str, str]: + metadata_calls.append({"agent_config": agent_config, "pyproject": project, **kwargs}) + return extracted_meta + + def _default_tag(meta: dict[str, str]) -> str: + tag_calls.append(meta) + return "fabric-agent-abc123:1.0.0" + + monkeypatch.setattr(metadata, "extract_agent_metadata", _extract) + monkeypatch.setattr(builder, "_default_tag_from_meta", _default_tag) + + result = builder.build_fabric_agent_image( + agent_config_path, + pyproject=pyproject, + base_image_url="registry.example/base", + base_image_tag="release", + python_version="3.13", + uv_version="0.8.15", + agent_version="2.0.0", + agent_author="Agent Author", + skip_validation=True, + ) + + assert metadata_calls == [ + { + "agent_config": agent_config_path, + "pyproject": pyproject, + "agent_version": "2.0.0", + "agent_author": "Agent Author", + "build_env": { + "base_image_url": "registry.example/base", + "base_image_tag": "release", + "python_version": "3.13", + "uv_version": "0.8.15", + }, + } + ] + assert tag_calls == [extracted_meta] + assert result == "fabric-agent-abc123:1.0.0" + + def test_renders_generated_dockerfile(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import nemo_agents_plugin.container.builder as builder + from nemo_agents_plugin.container import metadata, template + from nemo_agents_plugin.container.builder import build_fabric_agent_image + + agent_config_path = _write_package_config(tmp_path / "configs" / "agent.yaml") + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "fabric-agent"\nversion = "1.0.0"\n') + image_metadata = _image_metadata() + render_calls: list[tuple[tuple[object, ...], dict[str, object]]] = [] + build_calls: list[dict[str, object]] = [] + + monkeypatch.setattr(metadata, "extract_agent_metadata", lambda *args, **kwargs: image_metadata) + + def _render(*args: object, **kwargs: object) -> str: + render_calls.append((args, kwargs)) + return "FROM scratch\n" + + def _build(**kwargs: object) -> str: + dockerfile = kwargs["dockerfile"] + assert isinstance(dockerfile, Path) + assert dockerfile.read_text() == "FROM scratch\n" + build_calls.append(kwargs) + return "fabric-agent:test" + + monkeypatch.setattr(template, "render_fabric_dockerfile", _render) + monkeypatch.setattr(builder, "docker_build", _build) + + result = build_fabric_agent_image( + agent_config_path, + pyproject=pyproject, + tag="fabric-agent:test", + base_image_url="registry.example/base", + base_image_tag="release", + python_version="3.13", + uv_version="0.8.15", + allow_root=True, + sandbox_runtime="openshell", + agent_version="2.0.0", + agent_author="Agent Author", + template_path="Dockerfile.fabric.j2", + skip_validation=True, + platforms=["linux/amd64"], + push=True, + ) + + assert result == "fabric-agent:test" + assert render_calls == [ + ( + (agent_config_path, pyproject), + { + "base_image_url": "registry.example/base", + "base_image_tag": "release", + "python_version": "3.13", + "uv_version": "0.8.15", + "allow_root": True, + "sandbox_runtime": "openshell", + "agent_version": "2.0.0", + "agent_author": "Agent Author", + "template_path": "Dockerfile.fabric.j2", + "metadata": image_metadata, + }, + ) + ] + assert build_calls == [ + { + "context_dir": tmp_path.resolve(), + "dockerfile": tmp_path / "Dockerfile.generated", + "tag": "fabric-agent:test", + "build_args": { + "BASE_IMAGE_URL": "registry.example/base", + "BASE_IMAGE_TAG": "release", + "PYTHON_VERSION": "3.13", + }, + "platforms": ["linux/amd64"], + "push": True, + } + ] + assert not (tmp_path / "Dockerfile.generated").exists() + assert not (tmp_path / ".dockerignore").exists() + + def test_preserves_user_owned_dockerignore(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_agents_plugin.container import metadata, template + from nemo_agents_plugin.container.builder import build_fabric_agent_image + + agent_config_path = _write_package_config(tmp_path / "agent.yaml") + dockerignore = tmp_path / ".dockerignore" + user_content = "custom-output/\n" + dockerignore.write_text(user_content) + + monkeypatch.setattr(metadata, "extract_agent_metadata", lambda *args, **kwargs: _image_metadata()) + monkeypatch.setattr(template, "render_fabric_dockerfile", lambda *args, **kwargs: "FROM scratch\n") + + build_fabric_agent_image( + agent_config_path, + tag="fabric-agent:test", + skip_validation=True, + ) + + assert dockerignore.read_text() == user_content + assert not (tmp_path / "Dockerfile.generated").exists() + + def test_skips_dockerignore_generation_when_disabled(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_agents_plugin.container import metadata, template + from nemo_agents_plugin.container.builder import build_fabric_agent_image + + agent_config_path = _write_package_config(tmp_path / "agent.yaml") + monkeypatch.setattr(metadata, "extract_agent_metadata", lambda *args, **kwargs: _image_metadata()) + monkeypatch.setattr(template, "render_fabric_dockerfile", lambda *args, **kwargs: "FROM scratch\n") + + build_fabric_agent_image( + agent_config_path, + tag="fabric-agent:test", + skip_validation=True, + generate_ignore=False, + ) + + assert not (tmp_path / ".dockerignore").exists() + + def test_cleans_transient_files_when_build_fails(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import nemo_agents_plugin.container.builder as builder + from nemo_agents_plugin.container import metadata, template + + agent_config_path = _write_package_config(tmp_path / "agent.yaml") + monkeypatch.setattr(metadata, "extract_agent_metadata", lambda *args, **kwargs: _image_metadata()) + monkeypatch.setattr(template, "render_fabric_dockerfile", lambda *args, **kwargs: "FROM scratch\n") + + def _failed_build(**kwargs: object) -> str: + del kwargs + raise RuntimeError("docker build failed") + + monkeypatch.setattr(builder, "docker_build", _failed_build) + + with pytest.raises(RuntimeError, match="docker build failed"): + builder.build_fabric_agent_image( + agent_config_path, + tag="fabric-agent:test", + skip_validation=True, + ) + + assert not (tmp_path / "Dockerfile.generated").exists() + assert not (tmp_path / ".dockerignore").exists() + + def test_preserves_preexisting_managed_dockerignore(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_agents_plugin.container import metadata, template + from nemo_agents_plugin.container.builder import build_fabric_agent_image + from nemo_agents_plugin.container.template import DOCKERIGNORE_SENTINEL + + agent_config_path = _write_package_config(tmp_path / "agent.yaml") + dockerignore = tmp_path / ".dockerignore" + dockerignore.write_text(f"{DOCKERIGNORE_SENTINEL}\n# committed file\n") + monkeypatch.setattr(metadata, "extract_agent_metadata", lambda *args, **kwargs: _image_metadata()) + monkeypatch.setattr(template, "render_fabric_dockerfile", lambda *args, **kwargs: "FROM scratch\n") + + build_fabric_agent_image( + agent_config_path, + tag="fabric-agent:test", + skip_validation=True, + ) + + assert dockerignore.exists() + assert dockerignore.read_text().splitlines()[0] == DOCKERIGNORE_SENTINEL + + def test_refuses_to_overwrite_generated_dockerfile(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from nemo_agents_plugin.container import metadata, template + from nemo_agents_plugin.container.builder import build_fabric_agent_image + + agent_config_path = _write_package_config(tmp_path / "agent.yaml") + generated = tmp_path / "Dockerfile.generated" + user_content = "FROM user-owned-image\n" + generated.write_text(user_content) + monkeypatch.setattr(metadata, "extract_agent_metadata", lambda *args, **kwargs: _image_metadata()) + monkeypatch.setattr(template, "render_fabric_dockerfile", lambda *args, **kwargs: "FROM scratch\n") + + with pytest.raises(typer.Exit): + build_fabric_agent_image( + agent_config_path, + tag="fabric-agent:test", + skip_validation=True, + ) + + assert generated.read_text() == user_content + + def test_builds_with_user_provided_dockerfile(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import nemo_agents_plugin.container.builder as builder + import nemo_agents_plugin.container.metadata as metadata + import nemo_agents_plugin.container.template as template + + agent_config_path = _write_package_config(tmp_path / "configs" / "agent.yaml") + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "fabric-agent"\nversion = "1.0.0"\n') + dockerfile = tmp_path / "Dockerfile.custom" + dockerfile.write_text("FROM scratch\n") + build_calls: list[dict[str, object]] = [] + + monkeypatch.setattr(metadata, "extract_agent_metadata", lambda *args, **kwargs: _image_metadata()) + + def _unexpected_render(*args: object, **kwargs: object) -> str: + raise AssertionError(f"unexpected Fabric Dockerfile render: {args}, {kwargs}") + + monkeypatch.setattr(template, "render_fabric_dockerfile", _unexpected_render) + + def _build(**kwargs: object) -> str: + build_calls.append(kwargs) + return "fabric-agent:test" + + monkeypatch.setattr(builder, "docker_build", _build) + + result = builder.build_fabric_agent_image( + agent_config_path, + pyproject=pyproject, + dockerfile=dockerfile, + tag="fabric-agent:test", + base_image_url="registry.example/base", + base_image_tag="release", + python_version="3.13", + uv_version="0.8.15", + skip_validation=True, + platforms=["linux/amd64"], + push=True, + ) + + assert result == "fabric-agent:test" + assert build_calls == [ + { + "context_dir": tmp_path.resolve(), + "dockerfile": dockerfile, + "tag": "fabric-agent:test", + "build_args": { + "BASE_IMAGE_URL": "registry.example/base", + "BASE_IMAGE_TAG": "release", + "PYTHON_VERSION": "3.13", + }, + "platforms": ["linux/amd64"], + "push": True, + } + ] + assert "NAT_VERSION" not in build_calls[0]["build_args"] + assert not (tmp_path / "Dockerfile.generated").exists() + assert not (tmp_path / ".dockerignore").exists() class TestValidateFabricPackageArtifacts: From 97c7fccd6ca02275aec22854d0368075735a193f Mon Sep 17 00:00:00 2001 From: Manjesh Mogallapalli Date: Sat, 1 Aug 2026 16:28:39 -0500 Subject: [PATCH 07/13] image metadata and identity Signed-off-by: Manjesh Mogallapalli --- .../nemo_agents_plugin/container/builder.py | 13 +- .../nemo_agents_plugin/container/metadata.py | 81 ++++++----- .../nemo_agents_plugin/container/template.py | 8 +- .../nemo-agents/tests/unit/test_container.py | 137 +++++++++++++++++- .../unit/test_fabric_package_validation.py | 48 +++++- 5 files changed, 243 insertions(+), 44 deletions(-) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py b/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py index 08bc6bdab0..a8dad59040 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py @@ -286,16 +286,25 @@ def build_fabric_agent_image( asyncio.run(validate_fabric_agent_package(agent_config, context_dir=context_dir)) - from nemo_agents_plugin.container.template import render_dockerignore, render_fabric_dockerfile, resolve_value + from nemo_agents_plugin.container.template import ( + PINNED_NEMO_RELAY_CLI_VERSION, + get_contract_version, + render_dockerignore, + render_fabric_dockerfile, + resolve_value, + ) resolved_base_url = resolve_value("base_image_url", base_image_url) resolved_base_tag = resolve_value("base_image_tag", base_image_tag) resolved_python = resolve_value("python_version", python_version) resolved_uv = resolve_value("uv_version", uv_version) - from nemo_agents_plugin.container.metadata import extract_agent_metadata + from nemo_agents_plugin.container.metadata import NEMO_PLATFORM_AGENT_FRAMEWORK, extract_agent_metadata build_env_for_id = { + "agent_framework": NEMO_PLATFORM_AGENT_FRAMEWORK, + "contract_version": get_contract_version(), + "nemo_relay_cli_version": PINNED_NEMO_RELAY_CLI_VERSION, "base_image_url": resolved_base_url, "base_image_tag": resolved_base_tag, "python_version": resolved_python, diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/container/metadata.py b/plugins/nemo-agents/src/nemo_agents_plugin/container/metadata.py index 0df0f735f8..de487efe8c 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/container/metadata.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/container/metadata.py @@ -16,6 +16,9 @@ from pathlib import Path import yaml +from nemo_agents_plugin.entities import NEMO_AGENTS_SPEC_CONFIG_FORMAT + +NEMO_PLATFORM_AGENT_FRAMEWORK = "nemo_platform_agent" def extract_agent_metadata( @@ -30,16 +33,19 @@ def extract_agent_metadata( Resolution order for each field: - * **agent_name**: ``pyproject [project].name`` → config file stem + * **agent_name**: ``pyproject [project].name`` → Platform config ``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_framework**: ``"nemo_platform_agent"`` for Platform-owned agent + specs, ``"nemo_agent_toolkit"`` when config has a ``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"`` + * **description**: ``pyproject [project].description`` → Platform config + ``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 → ``""`` @@ -51,6 +57,7 @@ def extract_agent_metadata( """ pyproject_data = _load_pyproject(pyproject) config_text = agent_config.read_text(encoding="utf-8") if agent_config.exists() else "" + config_data = _parse_config(config_text) # Git commands and timestamp resolution all operate against the project's # repo, not the CLI's cwd. Without this, running the packager from `~` @@ -58,13 +65,13 @@ def extract_agent_metadata( # (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) + name = _resolve_name(pyproject_data, config_data, agent_config) version = _resolve_version(agent_version, pyproject_data) author = _resolve_author(agent_author, cwd=cwd) - framework = _detect_framework(config_text) + framework = _detect_framework(config_data) agent_id = _compute_agent_id(config_text, pyproject, build_env=build_env) timestamp = _resolve_timestamp(cwd=cwd) - description = _resolve_description(pyproject_data, config_text) + description = _resolve_description(pyproject_data, config_data) licenses = _resolve_licenses(pyproject_data) revision = _git_revision(cwd=cwd) source = _git_source(cwd=cwd) @@ -101,10 +108,23 @@ def _load_pyproject(pyproject: Path | None) -> dict: return {} -def _resolve_name(pyproject_data: dict, agent_config: Path) -> str: - name = pyproject_data.get("project", {}).get("name", "") - if name: - return name +def _parse_config(config_text: str) -> dict: + """Parse agent YAML once for all metadata field resolvers.""" + try: + data = yaml.safe_load(config_text) + except yaml.YAMLError: + return {} + return data if isinstance(data, dict) else {} + + +def _resolve_name(pyproject_data: dict, config_data: dict, agent_config: Path) -> str: + project_name = pyproject_data.get("project", {}).get("name", "") + if isinstance(project_name, str) and project_name: + return project_name + if config_data.get("config_format") == NEMO_AGENTS_SPEC_CONFIG_FORMAT: + config_name = config_data.get("name", "") + if isinstance(config_name, str) and config_name: + return config_name return agent_config.stem @@ -172,15 +192,10 @@ def _resolve_timestamp(cwd: Path | None = None) -> str: 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: +def _detect_framework(config_data: dict) -> str: + if config_data.get("config_format") == NEMO_AGENTS_SPEC_CONFIG_FORMAT: + return NEMO_PLATFORM_AGENT_FRAMEWORK + if "workflow" in config_data: return "nemo_agent_toolkit" return "unknown" @@ -194,9 +209,9 @@ def _compute_agent_id( 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. + *build_env* (runtime contract, dependency pins, base image, and Python + toolchain) so a rebuild with a different runtime produces a distinct id, + instead of silently re-tagging an incompatible image with the same suffix. """ hasher = hashlib.sha256() hasher.update(b"agent_config\0") @@ -211,21 +226,17 @@ def _compute_agent_id( return hasher.hexdigest()[:12] -def _resolve_description(pyproject_data: dict, config_text: str) -> str: +def _resolve_description(pyproject_data: dict, config_data: dict) -> str: desc = pyproject_data.get("project", {}).get("description", "") - if desc: + if isinstance(desc, str) and 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" + if config_data.get("config_format") == NEMO_AGENTS_SPEC_CONFIG_FORMAT: + config_description = config_data.get("description", "") + if isinstance(config_description, str) and config_description: + return config_description + wf = config_data.get("workflow", {}) + if isinstance(wf, dict) and wf.get("_type"): + return f"{wf['_type']} agent" return "" diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/container/template.py b/plugins/nemo-agents/src/nemo_agents_plugin/container/template.py index b74a43c1b2..6ea4d5e618 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/container/template.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/container/template.py @@ -89,7 +89,7 @@ def is_plugin_managed(path: Path) -> bool: "uv_version": "NEMO_AGENTS_UV_VERSION", } -_PINNED_NEMO_RELAY_CLI_VERSION = "0.6.0" +PINNED_NEMO_RELAY_CLI_VERSION = "0.6.0" # -- Jinja2 template -------------------------------------------------------- @@ -419,7 +419,7 @@ def _jinja_env() -> jinja2.Environment: undefined=jinja2.StrictUndefined, ) env.filters["dockerfile_escape"] = _dockerfile_escape - env.globals["pinned_nemo_relay_cli_version"] = _PINNED_NEMO_RELAY_CLI_VERSION + env.globals["pinned_nemo_relay_cli_version"] = PINNED_NEMO_RELAY_CLI_VERSION return env @@ -491,7 +491,7 @@ def resolve_shared_render_params( sandbox_runtime=sandbox_runtime_name, sandbox_apt_packages=sandbox_apt_packages, sandbox_user_setup=sandbox_user_setup, - contract_version=_get_contract_version(), + contract_version=get_contract_version(), agent_id=metadata["agent_id"], agent_name=metadata["agent_name"], agent_version=metadata["agent_version"], @@ -640,7 +640,7 @@ def render_fabric_dockerfile( return template.render(**_render_context(params)) -def _get_contract_version() -> str: +def get_contract_version() -> str: """Return the ``nemo-agents-plugin`` package version.""" from importlib.metadata import PackageNotFoundError, version diff --git a/plugins/nemo-agents/tests/unit/test_container.py b/plugins/nemo-agents/tests/unit/test_container.py index 4e09bcb4f9..61b430d326 100644 --- a/plugins/nemo-agents/tests/unit/test_container.py +++ b/plugins/nemo-agents/tests/unit/test_container.py @@ -396,11 +396,11 @@ def _render(**overrides: object) -> str: return _jinja_env().from_string(FABRIC_DOCKERFILE_TEMPLATE).render(**asdict(params)) def test_installs_pinned_relay_cli_globally(self) -> None: - from nemo_agents_plugin.container.template import _PINNED_NEMO_RELAY_CLI_VERSION + from nemo_agents_plugin.container.template import PINNED_NEMO_RELAY_CLI_VERSION result = self._render() - assert f"NEMO_RELAY_VERSION={_PINNED_NEMO_RELAY_CLI_VERSION}" in result + assert f"NEMO_RELAY_VERSION={PINNED_NEMO_RELAY_CLI_VERSION}" in result assert "--install-dir /usr/local/bin" in result assert "nemo-relay --version" in result assert "ARG NEMO_RELAY" not in result @@ -445,6 +445,34 @@ def test_config_only_mode(self, tmp_path: Path) -> None: assert "ENV AGENT_CONFIG_PATH=/workspace/agent.yaml" in result assert "NAT_VERSION" not in result + def test_renders_platform_agent_oci_labels(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + from nemo_agents_plugin.container.template import get_contract_version, render_fabric_dockerfile + + agent_config = tmp_path / "agent.yaml" + agent_config.write_text( + "config_format: nemo-agents-spec-v1\nname: research-assistant\ndescription: Researches technical topics\n" + ) + metadata = extract_agent_metadata( + agent_config, + agent_version="2.0.0", + agent_author="Agent Author", + ) + + result = render_fabric_dockerfile( + agent_config, + agent_version="2.0.0", + agent_author="Agent Author", + ) + + assert 'org.opencontainers.image.title="research-assistant"' in result + assert 'org.opencontainers.image.version="2.0.0"' in result + assert 'org.opencontainers.image.authors="Agent Author"' in result + assert 'org.opencontainers.image.description="Researches technical topics"' in result + assert f'com.nemo.agent.id="{metadata["agent_id"]}"' in result + assert 'com.nemo.agent.framework="nemo_platform_agent"' in result + assert f'com.nemo.agent.contract-version="{get_contract_version()}"' in result + def test_project_mode_preserves_relative_config_path(self, tmp_path: Path) -> None: from nemo_agents_plugin.container.template import render_fabric_dockerfile @@ -587,6 +615,14 @@ def test_contents(self, tmp_path: Path) -> None: class TestExtractAgentMetadata: + def test_parses_agent_config_once(self, agent_config: Path) -> None: + import nemo_agents_plugin.container.metadata as metadata + + with patch.object(metadata.yaml, "safe_load", wraps=metadata.yaml.safe_load) as mock_safe_load: + metadata.extract_agent_metadata(agent_config, agent_author="x") + + mock_safe_load.assert_called_once() + def test_basic_extraction(self, agent_config: Path) -> None: from nemo_agents_plugin.container.metadata import extract_agent_metadata @@ -617,6 +653,38 @@ def test_pyproject_overrides_name_and_version(self, project_dir: tuple[Path, Pat assert meta["agent_name"] == "test-agent" assert meta["agent_version"] == "2.3.0" + def test_fabric_config_name_used_without_pyproject(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + config = tmp_path / "agent.yaml" + config.write_text("config_format: nemo-agents-spec-v1\nname: research-assistant\n") + + meta = extract_agent_metadata(config, agent_author="x") + + assert meta["agent_name"] == "research-assistant" + + def test_fabric_config_uses_platform_agent_framework(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + config = tmp_path / "agent.yaml" + config.write_text("config_format: nemo-agents-spec-v1\nname: research-assistant\n") + + meta = extract_agent_metadata(config, agent_author="x") + + assert meta["agent_framework"] == "nemo_platform_agent" + + def test_pyproject_name_overrides_fabric_config_name(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + config = tmp_path / "agent.yaml" + config.write_text("config_format: nemo-agents-spec-v1\nname: config-name\n") + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "project-name"\nversion = "1.0.0"\n') + + meta = extract_agent_metadata(config, pyproject, agent_author="x") + + assert meta["agent_name"] == "project-name" + def test_explicit_overrides_take_priority(self, agent_config: Path) -> None: from nemo_agents_plugin.container.metadata import extract_agent_metadata @@ -648,6 +716,27 @@ def test_agent_id_differs_for_different_config(self, tmp_path: Path) -> None: m2 = extract_agent_metadata(c2, agent_author="x") assert m1["agent_id"] != m2["agent_id"] + def test_fabric_agent_id_includes_runtime_contract(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + config = tmp_path / "agent.yaml" + config.write_text("config_format: nemo-agents-spec-v1\nname: research-assistant\n") + baseline = { + "agent_framework": "nemo_platform_agent", + "contract_version": "1.0.0", + "nemo_relay_cli_version": "0.6.0", + } + + baseline_id = extract_agent_metadata(config, agent_author="x", build_env=baseline)["agent_id"] + + for key, value in ( + ("agent_framework", "different-runtime"), + ("contract_version", "2.0.0"), + ("nemo_relay_cli_version", "0.7.0"), + ): + changed = {**baseline, key: value} + assert extract_agent_metadata(config, agent_author="x", build_env=changed)["agent_id"] != baseline_id + def test_no_workflow_key_gives_unknown_framework(self, tmp_path: Path) -> None: from nemo_agents_plugin.container.metadata import extract_agent_metadata @@ -657,6 +746,20 @@ def test_no_workflow_key_gives_unknown_framework(self, tmp_path: Path) -> None: meta = extract_agent_metadata(config, agent_author="x") assert meta["agent_framework"] == "unknown" + def test_nat_metadata_ignores_platform_only_top_level_fields(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + config = tmp_path / "nat-config.yaml" + config.write_text( + "name: platform-style-name\ndescription: Platform-style description\nworkflow:\n _type: react_agent\n" + ) + + meta = extract_agent_metadata(config, agent_author="x") + + assert meta["agent_name"] == "nat-config" + assert meta["agent_framework"] == "nemo_agent_toolkit" + assert meta["description"] == "react_agent agent" + def test_git_failure_falls_back_to_unknown(self, agent_config: Path) -> None: from nemo_agents_plugin.container.metadata import extract_agent_metadata @@ -687,6 +790,36 @@ def test_description_from_pyproject(self, tmp_path: Path) -> None: meta = extract_agent_metadata(config, pyproject, agent_author="x") assert meta["description"] == "Handles math queries" + def test_description_from_fabric_config(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + config = tmp_path / "agent.yaml" + config.write_text( + "config_format: nemo-agents-spec-v1\n" + "name: research-assistant\n" + "description: Researches and summarizes technical topics\n" + ) + + meta = extract_agent_metadata(config, agent_author="x") + + assert meta["description"] == "Researches and summarizes technical topics" + + def test_pyproject_description_overrides_fabric_config(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.metadata import extract_agent_metadata + + config = tmp_path / "agent.yaml" + config.write_text( + "config_format: nemo-agents-spec-v1\nname: research-assistant\ndescription: Config description\n" + ) + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + '[project]\nname = "research-assistant"\nversion = "1.0.0"\ndescription = "Project description"\n' + ) + + meta = extract_agent_metadata(config, pyproject, agent_author="x") + + assert meta["description"] == "Project description" + def test_description_fallback_to_workflow_type(self, agent_config: Path) -> None: from nemo_agents_plugin.container.metadata import extract_agent_metadata diff --git a/plugins/nemo-agents/tests/unit/test_fabric_package_validation.py b/plugins/nemo-agents/tests/unit/test_fabric_package_validation.py index 6fc84545a7..24debf311e 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_package_validation.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_package_validation.py @@ -57,7 +57,7 @@ def _image_metadata() -> dict[str, str]: "agent_id": "abc123", "agent_version": "1.0.0", "agent_author": "Agent Author", - "agent_framework": "unknown", + "agent_framework": "nemo_platform_agent", "build_timestamp": "2026-08-01T00:00:00+00:00", "description": "Fabric agent", "licenses": "Apache-2.0", @@ -264,6 +264,7 @@ def _resolve(name: str, explicit: str | None = None) -> str: def test_extracts_metadata_and_derives_default_tag(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: import nemo_agents_plugin.container.builder as builder import nemo_agents_plugin.container.metadata as metadata + import nemo_agents_plugin.container.template as template agent_config_path = _write_package_config(tmp_path / "agent.yaml") pyproject = tmp_path / "pyproject.toml" @@ -286,6 +287,7 @@ def _default_tag(meta: dict[str, str]) -> str: monkeypatch.setattr(metadata, "extract_agent_metadata", _extract) monkeypatch.setattr(builder, "_default_tag_from_meta", _default_tag) + monkeypatch.setattr(template, "get_contract_version", lambda: "1.0.0") result = builder.build_fabric_agent_image( agent_config_path, @@ -306,6 +308,9 @@ def _default_tag(meta: dict[str, str]) -> str: "agent_version": "2.0.0", "agent_author": "Agent Author", "build_env": { + "agent_framework": "nemo_platform_agent", + "contract_version": "1.0.0", + "nemo_relay_cli_version": "0.6.0", "base_image_url": "registry.example/base", "base_image_tag": "release", "python_version": "3.13", @@ -316,6 +321,47 @@ def _default_tag(meta: dict[str, str]) -> str: assert tag_calls == [extracted_meta] assert result == "fabric-agent-abc123:1.0.0" + def test_default_tag_uses_fabric_name_and_runtime_identity(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.builder import build_fabric_agent_image + from nemo_agents_plugin.container.metadata import ( + NEMO_PLATFORM_AGENT_FRAMEWORK, + extract_agent_metadata, + ) + from nemo_agents_plugin.container.template import ( + PINNED_NEMO_RELAY_CLI_VERSION, + get_contract_version, + ) + + agent_config_path = _write_package_config(tmp_path / "agent.yaml") + build_env = { + "agent_framework": NEMO_PLATFORM_AGENT_FRAMEWORK, + "contract_version": get_contract_version(), + "nemo_relay_cli_version": PINNED_NEMO_RELAY_CLI_VERSION, + "base_image_url": "registry.example/base", + "base_image_tag": "release", + "python_version": "3.13", + "uv_version": "0.8.15", + } + expected_metadata = extract_agent_metadata( + agent_config_path, + agent_version="2.0.0", + agent_author="Agent Author", + build_env=build_env, + ) + + result = build_fabric_agent_image( + agent_config_path, + base_image_url="registry.example/base", + base_image_tag="release", + python_version="3.13", + uv_version="0.8.15", + agent_version="2.0.0", + agent_author="Agent Author", + skip_validation=True, + ) + + assert result == f"packaged-agent-{expected_metadata['agent_id']}:2.0.0" + def test_renders_generated_dockerfile(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: import nemo_agents_plugin.container.builder as builder from nemo_agents_plugin.container import metadata, template From 7aa5624e50b0fb26b8c3d70cbd4b2befd7d7bb8d Mon Sep 17 00:00:00 2001 From: Manjesh Mogallapalli Date: Sat, 1 Aug 2026 16:40:08 -0500 Subject: [PATCH 08/13] tests Signed-off-by: Manjesh Mogallapalli --- .../unit/test_fabric_package_validation.py | 36 +++++++++++++++++++ .../tests/unit/test_fabric_server.py | 25 +++++++++++++ 2 files changed, 61 insertions(+) diff --git a/plugins/nemo-agents/tests/unit/test_fabric_package_validation.py b/plugins/nemo-agents/tests/unit/test_fabric_package_validation.py index 24debf311e..3bbd5f9ff8 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_package_validation.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_package_validation.py @@ -443,6 +443,42 @@ def _build(**kwargs: object) -> str: assert not (tmp_path / "Dockerfile.generated").exists() assert not (tmp_path / ".dockerignore").exists() + def test_packages_nested_config_and_skill(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import nemo_agents_plugin.container.builder as builder + + agent_config_path = _write_package_config(tmp_path / "configs" / "agent.yaml", "../skills/review") + skill = _skill(tmp_path / "skills" / "review") + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "fabric-agent"\nversion = "1.0.0"\n') + + monkeypatch.setattr(fabric_validator, "translate_agent_config", lambda config: object()) + + async def _plan(config: object, *, base_dir: Path, fabric: Any | None = None) -> object: + del config, base_dir, fabric + return {"plan": "ok"} + + def _build(**kwargs: object) -> str: + dockerfile = kwargs["dockerfile"] + assert isinstance(dockerfile, Path) + content = dockerfile.read_text() + assert "COPY ./ /workspace" in content + assert "ENV AGENT_CONFIG_PATH=/workspace/configs/agent.yaml" in content + assert (skill / "SKILL.md").is_file() + return "fabric-agent:test" + + monkeypatch.setattr(fabric_validator, "plan_fabric_config", _plan) + monkeypatch.setattr(builder, "docker_build", _build) + + result = builder.build_fabric_agent_image( + agent_config_path, + pyproject=pyproject, + tag="fabric-agent:test", + ) + + assert result == "fabric-agent:test" + assert not (tmp_path / "Dockerfile.generated").exists() + assert not (tmp_path / ".dockerignore").exists() + def test_preserves_user_owned_dockerignore(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: from nemo_agents_plugin.container import metadata, template from nemo_agents_plugin.container.builder import build_fabric_agent_image diff --git a/plugins/nemo-agents/tests/unit/test_fabric_server.py b/plugins/nemo-agents/tests/unit/test_fabric_server.py index 421e38b771..5c4132f1ef 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_server.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_server.py @@ -8,6 +8,7 @@ from pathlib import Path from types import SimpleNamespace from typing import Any, cast +from unittest.mock import patch import pytest import yaml @@ -133,6 +134,30 @@ def test_startup_loads_and_validates_agent_config( assert mock_validate_agent_config == [(app.state.agent_config, tmp_path)] +def test_main_starts_packaged_server(tmp_path: Path) -> None: + config_path = _write_agent_config(tmp_path) + app = object() + + with ( + patch("nemo_agents_plugin.fabric.server.create_fabric_serving_app", return_value=app) as create_app, + patch("uvicorn.run") as run, + ): + result = server.main( + [ + "--agent-config", + str(config_path), + "--host", + "0.0.0.0", + "--port", + "8000", + ] + ) + + assert result == 0 + create_app.assert_called_once_with(config_path, settings=FabricServingSettings()) + run.assert_called_once_with(app, host="0.0.0.0", port=8000, log_config=None) + + def test_shutdown_stops_all_registered_runtimes( tmp_path: Path, mock_validate_agent_config: list[tuple[AgentConfig, Path]], From ff24682dd9ea8bb65f7763433428726d845dbc62 Mon Sep 17 00:00:00 2001 From: Manjesh Mogallapalli Date: Sat, 1 Aug 2026 22:09:28 -0500 Subject: [PATCH 09/13] placehold env key logic for IGW Signed-off-by: Manjesh Mogallapalli --- .../fabric/gateway_credentials.py | 84 ++++++++++++++++++ .../nemo_agents_plugin/fabric/translator.py | 9 +- .../runner/deployments_backend.py | 11 ++- .../nemo_agents_plugin/runner/in_memory.py | 17 +++- .../unit/test_fabric_gateway_credentials.py | 87 +++++++++++++++++++ .../tests/unit/test_fabric_translator.py | 4 + .../tests/unit/test_runner_deployments.py | 44 +++++++++- .../tests/unit/test_runner_in_memory.py | 34 ++++++-- 8 files changed, 277 insertions(+), 13 deletions(-) create mode 100644 plugins/nemo-agents/src/nemo_agents_plugin/fabric/gateway_credentials.py create mode 100644 plugins/nemo-agents/tests/unit/test_fabric_gateway_credentials.py diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/gateway_credentials.py b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/gateway_credentials.py new file mode 100644 index 0000000000..7a2402a697 --- /dev/null +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/gateway_credentials.py @@ -0,0 +1,84 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Transient placeholder credentials for Platform-routed Fabric models.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +PLATFORM_IGW_PATH_MARKER = "/apis/inference-gateway/" +PLATFORM_IGW_API_KEY_ENV = "NEMO_AGENTS_IGW_API_KEY" +PLATFORM_IGW_API_KEY_PLACEHOLDER = "not-used" + + +@dataclass(frozen=True, slots=True) +class PlatformGatewayCredentialBinding: + """Runtime-only credential binding required by a Fabric model adapter.""" + + api_key_env: str + value: str = PLATFORM_IGW_API_KEY_PLACEHOLDER + + +def resolve_platform_gateway_credential_binding( + config: Mapping[str, Any], +) -> PlatformGatewayCredentialBinding | None: + """Return the placeholder binding for the selected IGW-routed model.""" + model = _selected_model_config(config) + if model is None or not _is_platform_gateway_model(model): + return None + + api_key_env = model.get("api_key_env") + if not isinstance(api_key_env, str) or not api_key_env: + api_key_env = PLATFORM_IGW_API_KEY_ENV + return PlatformGatewayCredentialBinding(api_key_env=api_key_env) + + +def platform_gateway_credential_env(config: Mapping[str, Any]) -> dict[str, str]: + """Return child-process environment values for the selected Fabric model.""" + binding = resolve_platform_gateway_credential_binding(config) + if binding is None: + return {} + return {binding.api_key_env: binding.value} + + +def bind_platform_gateway_model_credential(model: Mapping[str, Any]) -> dict[str, Any]: + """Copy a translated model payload and add its runtime-only key reference.""" + resolved = dict(model) + if not _is_platform_gateway_model(resolved): + return resolved + api_key_env = resolved.get("api_key_env") + if not isinstance(api_key_env, str) or not api_key_env: + resolved["api_key_env"] = PLATFORM_IGW_API_KEY_ENV + return resolved + + +def _selected_model_config(config: Mapping[str, Any]) -> Mapping[str, Any] | None: + harnesses = config.get("harnesses") + default_harness = config.get("default_harness") + if isinstance(harnesses, Mapping) and isinstance(default_harness, str): + harness = harnesses.get(default_harness) + if isinstance(harness, Mapping): + model = harness.get("model") + if isinstance(model, Mapping): + return model + + models = config.get("models") + if not isinstance(models, Mapping): + return None + model = models.get("default") + return model if isinstance(model, Mapping) else None + + +def _is_platform_gateway_model(model: Mapping[str, Any]) -> bool: + base_url = model.get("base_url") + if isinstance(base_url, str): + return PLATFORM_IGW_PATH_MARKER in base_url + + settings = model.get("settings") + if not isinstance(settings, Mapping): + return False + legacy_base_url = settings.get("base_url") + return isinstance(legacy_base_url, str) and PLATFORM_IGW_PATH_MARKER in legacy_base_url diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py index 0bc3d19295..1a98ff0ed7 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/fabric/translator.py @@ -10,6 +10,10 @@ # CI type-checks this plugin via ty extra-paths without installing nemo-agents deps. import nemo_fabric as fabric # ty: ignore[unresolved-import] from nemo_agents_plugin.agent_config import AgentConfig, HarnessConfig, ModelConfig +from nemo_agents_plugin.fabric.gateway_credentials import ( + bind_platform_gateway_model_credential, + platform_gateway_credential_env, +) HARNESS_ADAPTER_IDS = { "claude": "nvidia.fabric.claude", @@ -27,6 +31,8 @@ def translate_agent_config(config: AgentConfig, harness_name: str | None = None) """Translate Platform-owned agent config into a typed in-memory FabricConfig.""" selected_harness_name, harness = _select_harness(config, harness_name) model = _resolve_model(config, selected_harness_name, harness) + model_payload = bind_platform_gateway_model_credential(_model_payload(model)) + runtime_env = platform_gateway_credential_env({"models": {"default": model_payload}}) _validate_untranslated_shared_fields(config) fabric_config = fabric.FabricConfig( @@ -37,13 +43,14 @@ def translate_agent_config(config: AgentConfig, harness_name: str | None = None) settings=harness.settings, ), models={ - "default": fabric.ModelConfig(**_model_payload(model)), + "default": fabric.ModelConfig(**model_payload), }, instructions=_instructions_config(config), environment=fabric.EnvironmentConfig( provider=config.environment.provider, workspace=config.environment.workspace, artifacts=config.environment.artifacts, + env=runtime_env, settings=config.environment.settings, ), skills=_skills_config(config), diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py b/plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py index 80b68204f4..33c1023266 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py @@ -32,6 +32,7 @@ DeploymentStatus, Endpoint, ) +from nemo_agents_plugin.fabric.gateway_credentials import platform_gateway_credential_env from nemo_agents_plugin.runner.backend import DeploymentInfo, ExternalLog, LogLocation, RunnerBackend from nemo_agents_plugin.utils import get_base_url, get_internal_base_url from nemo_deployments_plugin.auth_proxy import auth_proxy_port @@ -320,6 +321,10 @@ def build_deployment_config( ] if is_fabric: env.append(EnvVar(name=_AGENT_CONFIG_PATH_ENV, value=config_path)) + env.extend( + EnvVar(name=env_name, value=env_value) + for env_name, env_value in platform_gateway_credential_env(agent_config).items() + ) else: env.append(EnvVar(name=_NAT_CONFIG_ENV, value=config_mount_path)) volume_mounts: list[VolumeMount] = [] @@ -460,9 +465,9 @@ async def create_deployment( ) entities = self._entity_client() - # The base_url injected into the agent config at agent-create time is the - # platform's own base URL, which is not necessarily reachable from inside - # the agent container. Rebase it onto a container-reachable address. + # Deployment resolution injects the platform's own base URL, which is not + # necessarily reachable from inside the agent container. Rebase it onto a + # container-reachable address. try: internal_base_url = self._config.k8s_internal_base_url or get_internal_base_url() gateway = resolve_agent_gateway_url( diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/runner/in_memory.py b/plugins/nemo-agents/src/nemo_agents_plugin/runner/in_memory.py index 62c88e74c5..fe6ab5b442 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/runner/in_memory.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/runner/in_memory.py @@ -25,6 +25,7 @@ import asyncio import logging +import os import re import shutil import socket @@ -37,6 +38,7 @@ import yaml from nemo_agents_plugin.config import AgentsConfig, ControllerConfig from nemo_agents_plugin.entities import AGENT_CONFIG_FILENAME, NEMO_AGENTS_SPEC_CONFIG_FORMAT, DeploymentMode +from nemo_agents_plugin.fabric.gateway_credentials import platform_gateway_credential_env from nemo_agents_plugin.runner.backend import DeploymentInfo, LocalLog, LogLocation, NotYetAvailable, RunnerBackend # Match characters not safe for filesystem paths. Deployment names are @@ -274,7 +276,15 @@ async def _create_fabric_deployment( config_path = await asyncio.to_thread(self._write_fabric_config, base_dir, config) await validate_platform_agent_config(config, base_dir=base_dir) log_path = self.log_path_for(workspace, name) - proc = await asyncio.to_thread(self._spawn_fabric, name, config_path, log_path, port) + credential_env = platform_gateway_credential_env(config) + proc = await asyncio.to_thread( + self._spawn_fabric, + name, + config_path, + log_path, + port, + credential_env, + ) except Exception: await asyncio.to_thread(shutil.rmtree, base_dir, ignore_errors=True) raise @@ -438,6 +448,7 @@ def _spawn_fabric( config_path: Path, log_path: Path, port: int, + credential_env: dict[str, str] | None = None, ) -> subprocess.Popen[bytes]: """Spawn the Platform-owned Fabric server on a loopback port.""" cmd = [ @@ -454,8 +465,10 @@ def _spawn_fabric( log_path.parent.mkdir(parents=True, exist_ok=True) logger.info("Spawning: %s (log: %s)", " ".join(cmd), log_path) log_file = log_path.open("w") + child_env = os.environ.copy() + child_env.update(credential_env or {}) try: - return subprocess.Popen(cmd, stdout=log_file, stderr=subprocess.STDOUT) + return subprocess.Popen(cmd, stdout=log_file, stderr=subprocess.STDOUT, env=child_env) finally: log_file.close() diff --git a/plugins/nemo-agents/tests/unit/test_fabric_gateway_credentials.py b/plugins/nemo-agents/tests/unit/test_fabric_gateway_credentials.py new file mode 100644 index 0000000000..fca95f2d72 --- /dev/null +++ b/plugins/nemo-agents/tests/unit/test_fabric_gateway_credentials.py @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import copy + +from nemo_agents_plugin.fabric.gateway_credentials import ( + PLATFORM_IGW_API_KEY_ENV, + PLATFORM_IGW_API_KEY_PLACEHOLDER, + bind_platform_gateway_model_credential, + platform_gateway_credential_env, +) + +_IGW_URL = "http://platform/apis/inference-gateway/v2/workspaces/default/openai/-/v1" + + +def test_selected_harness_model_binding_takes_precedence_over_shared_model() -> None: + config = { + "default_harness": "codex", + "harnesses": { + "codex": { + "model": { + "provider": "nvidia", + "model": "harness-model", + "base_url": _IGW_URL, + "api_key_env": "HARNESS_API_KEY", + } + } + }, + "models": { + "default": { + "provider": "openai", + "model": "shared-model", + "base_url": _IGW_URL, + "api_key_env": "SHARED_API_KEY", + } + }, + } + + assert platform_gateway_credential_env(config) == {"HARNESS_API_KEY": PLATFORM_IGW_API_KEY_PLACEHOLDER} + + +def test_shared_model_without_key_uses_runtime_only_binding_without_mutating_config() -> None: + config = { + "default_harness": "codex", + "harnesses": {"codex": {}}, + "models": {"default": {"provider": "nvidia", "model": "test-model", "base_url": _IGW_URL}}, + } + original = copy.deepcopy(config) + + assert platform_gateway_credential_env(config) == {PLATFORM_IGW_API_KEY_ENV: PLATFORM_IGW_API_KEY_PLACEHOLDER} + assert config == original + + +def test_explicit_third_party_endpoint_does_not_receive_placeholder() -> None: + config = { + "models": { + "default": { + "provider": "openai", + "model": "gpt-test", + "base_url": "https://api.openai.com/v1", + "api_key_env": "OPENAI_API_KEY", + } + } + } + + assert platform_gateway_credential_env(config) == {} + + +def test_translated_igw_model_receives_runtime_only_key_reference() -> None: + model = {"provider": "nvidia", "model": "test-model", "base_url": _IGW_URL} + + resolved = bind_platform_gateway_model_credential(model) + + assert resolved["api_key_env"] == PLATFORM_IGW_API_KEY_ENV + assert "api_key_env" not in model + + +def test_translated_direct_model_is_unchanged() -> None: + model = { + "provider": "nvidia", + "model": "test-model", + "base_url": "https://integrate.api.nvidia.com/v1", + } + + assert bind_platform_gateway_model_credential(model) == model diff --git a/plugins/nemo-agents/tests/unit/test_fabric_translator.py b/plugins/nemo-agents/tests/unit/test_fabric_translator.py index bd2aca47a5..b59c101f8a 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_translator.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_translator.py @@ -11,6 +11,7 @@ import pytest from nemo_agents_plugin.agent_config import AgentConfig, load_agent_config +from nemo_agents_plugin.fabric.gateway_credentials import PLATFORM_IGW_API_KEY_ENV, PLATFORM_IGW_API_KEY_PLACEHOLDER from nemo_agents_plugin.fabric.translator import FabricTranslationError, translate_agent_config @@ -125,6 +126,9 @@ def test_selected_harness_uses_default_model(self) -> None: fabric_config.models["default"].base_url == "http://platform:8080/apis/inference-gateway/v2/workspaces/default/openai/-/v1" ) + assert fabric_config.models["default"].api_key_env == PLATFORM_IGW_API_KEY_ENV + assert fabric_config.environment.env == {PLATFORM_IGW_API_KEY_ENV: PLATFORM_IGW_API_KEY_PLACEHOLDER} + assert config.models["default"].api_key_env is None def test_promotes_legacy_model_settings_base_url(self) -> None: payload = _example_yaml_config() diff --git a/plugins/nemo-agents/tests/unit/test_runner_deployments.py b/plugins/nemo-agents/tests/unit/test_runner_deployments.py index 5a2e8b0f15..f2a91c5b5f 100644 --- a/plugins/nemo-agents/tests/unit/test_runner_deployments.py +++ b/plugins/nemo-agents/tests/unit/test_runner_deployments.py @@ -12,6 +12,7 @@ import yaml from nemo_agents_plugin.config import AgentsConfig, DeploymentsRunnerConfig from nemo_agents_plugin.entities import Endpoint +from nemo_agents_plugin.fabric.gateway_credentials import PLATFORM_IGW_API_KEY_ENV, PLATFORM_IGW_API_KEY_PLACEHOLDER from nemo_agents_plugin.runner.deployments_backend import ( DeploymentsRunnerBackend, UnreachableGatewayURLError, @@ -263,7 +264,12 @@ def test_build_deployment_config_docker_never_emits_init_containers() -> None: "harnesses": { "main": { "provider": "codex", - "model": {"provider": "openai", "model": "test-model", "settings": {}}, + "model": { + "provider": "openai", + "model": "test-model", + "base_url": "http://platform/apis/inference-gateway/v2/workspaces/default/openai/-/v1", + "settings": {}, + }, } }, } @@ -302,6 +308,9 @@ def test_build_deployment_config_fabric_docker_uses_fabric_server() -> None: assert not any(e.name == "NAT_CONFIG_YAML" for e in container.env) assert any(e.name == "AGENT_CONFIG_PATH" and e.value == "/workspace/agent.yaml" for e in container.env) assert next(e.value for e in container.env if e.name == "NMP_BASE_URL") == "http://host.docker.internal:8080" + assert next(e.value for e in container.env if e.name == PLATFORM_IGW_API_KEY_ENV) == ( + PLATFORM_IGW_API_KEY_PLACEHOLDER + ) assert "nemo_agents_plugin.fabric.server" in container.args[0] assert container.readiness_probe is not None assert container.readiness_probe.http_get is not None @@ -328,9 +337,42 @@ def test_build_deployment_config_fabric_k8s_uses_fabric_entrypoint() -> None: assert "/workspace/agent.yaml" in container.args assert "--host" in container.args and "0.0.0.0" in container.args assert not any(e.name == "NAT_CONFIG_YAML" for e in container.env) + assert next(e.value for e in container.env if e.name == PLATFORM_IGW_API_KEY_ENV) == ( + PLATFORM_IGW_API_KEY_PLACEHOLDER + ) assert cfg.config_files[0].path == "/workspace/agent.yaml" +def test_build_deployment_config_fabric_direct_endpoint_has_no_placeholder() -> None: + config = { + **_FABRIC_AGENT_CONFIG, + "harnesses": { + "main": { + "provider": "codex", + "model": { + "provider": "openai", + "model": "gpt-test", + "base_url": "https://api.openai.com/v1", + "api_key_env": "OPENAI_API_KEY", + }, + } + }, + } + + cfg = build_deployment_config( + name="fabric-dep", + workspace="default", + image="fabric-runtime:latest", + port=8000, + agent_config=config, + platform_base_url="http://host.docker.internal:8080", + config_mount_path="/workspace/config.yaml", + mode="docker", + ) + + assert not any(e.name in {PLATFORM_IGW_API_KEY_ENV, "OPENAI_API_KEY"} for e in cfg.containers[0].env) + + def _backend(**deployments_kwargs: Any) -> DeploymentsRunnerBackend: agents = AgentsConfig.model_validate({"deployments": DeploymentsRunnerConfig(**deployments_kwargs)}) return DeploymentsRunnerBackend(agents) diff --git a/plugins/nemo-agents/tests/unit/test_runner_in_memory.py b/plugins/nemo-agents/tests/unit/test_runner_in_memory.py index 7a29ab785c..92dff03557 100644 --- a/plugins/nemo-agents/tests/unit/test_runner_in_memory.py +++ b/plugins/nemo-agents/tests/unit/test_runner_in_memory.py @@ -20,6 +20,7 @@ from __future__ import annotations +import os import subprocess import sys from pathlib import Path @@ -198,7 +199,14 @@ async def test_create_deployment_validates_platform_agent_config(tmp_path: Path) "name": "fabric-agent", "default_harness": "hermes", "harnesses": {"hermes": {"kind": "hermes"}}, - "models": {"default": {"provider": "openai", "model": "openai/gpt-5.4"}}, + "models": { + "default": { + "provider": "nvidia", + "model": "default/test-model", + "base_url": "http://platform/apis/inference-gateway/v2/workspaces/ws/openai/-/v1", + "api_key_env": "NVIDIA_API_KEY", + } + }, } validation_calls: list[Any] = [] @@ -208,8 +216,9 @@ async def _validate_platform_agent_config(config_: dict[str, Any], *, base_dir: fake_process = SimpleNamespace(pid=4242, returncode=None, poll=lambda: None) - def _spawn_fabric(self_, name, config_path, log_path, port): # noqa: ANN001 + def _spawn_fabric(self_, name, config_path, log_path, port, credential_env=None): # noqa: ANN001 del self_, name, config_path, port + assert credential_env == {"NVIDIA_API_KEY": "not-used"} log_path.parent.mkdir(parents=True, exist_ok=True) log_path.write_text("") return fake_process @@ -249,8 +258,8 @@ async def _validate_platform_agent_config(config_: dict[str, Any], *, base_dir: fake_process = SimpleNamespace(pid=4242, returncode=None, poll=lambda: None) terminate_calls: list[tuple[str, Any]] = [] - def _spawn_fabric(self_, name, config_path, log_path, port): # noqa: ANN001 - del self_, name, config_path, port + def _spawn_fabric(self_, name, config_path, log_path, port, credential_env=None): # noqa: ANN001 + del self_, name, config_path, port, credential_env log_path.parent.mkdir(parents=True, exist_ok=True) log_path.write_text("") return fake_process @@ -300,15 +309,25 @@ async def _validate_platform_agent_config(config_: dict[str, Any], *, base_dir: assert await backend.get_deployment_status("ws", "fabric-dep") is None -def test_spawn_fabric_uses_current_python_and_platform_server(tmp_path: Path) -> None: +def test_spawn_fabric_uses_current_python_and_platform_server( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: backend = _backend(tmp_path) config_path = tmp_path / "agent.yaml" config_path.write_text("name: test-agent\n") log_path = tmp_path / "agent.log" process = SimpleNamespace() + monkeypatch.setenv("NVIDIA_API_KEY", "real-controller-key") with patch("nemo_agents_plugin.runner.in_memory.subprocess.Popen", return_value=process) as popen: - spawned = backend._spawn_fabric("fabric-dep", config_path, log_path, 49212) + spawned = backend._spawn_fabric( + "fabric-dep", + config_path, + log_path, + 49212, + {"NVIDIA_API_KEY": "not-used"}, + ) assert spawned is process popen.assert_called_once_with( @@ -325,7 +344,10 @@ def test_spawn_fabric_uses_current_python_and_platform_server(tmp_path: Path) -> ], stdout=ANY, stderr=subprocess.STDOUT, + env=ANY, ) + assert popen.call_args.kwargs["env"]["NVIDIA_API_KEY"] == "not-used" + assert os.environ["NVIDIA_API_KEY"] == "real-controller-key" @pytest.mark.asyncio From fdffb7d9ed7fd6df3a6a75815d77a9866f2a7aba Mon Sep 17 00:00:00 2001 From: Manjesh Mogallapalli Date: Sat, 1 Aug 2026 23:09:31 -0500 Subject: [PATCH 10/13] patches Signed-off-by: Manjesh Mogallapalli --- .../src/nemo_agents_plugin/container/template.py | 11 ++++++----- plugins/nemo-agents/tests/unit/test_container.py | 15 ++++++++++----- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/container/template.py b/plugins/nemo-agents/src/nemo_agents_plugin/container/template.py index 6ea4d5e618..624d186e75 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/container/template.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/container/template.py @@ -65,7 +65,7 @@ def is_plugin_managed(path: Path) -> bool: "base_image_url": "nvcr.io/nvidia/base/ubuntu", "base_image_tag": "noble-20260217", "python_version": "3.13", - "uv_version": "0.8.15", + "uv_version": "0.9.14", # 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 @@ -235,14 +235,14 @@ def is_plugin_managed(path: Path) -> bool: 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 --prerelease=allow "nemo-agents-plugin=={{ contract_version }}" . && \\ + uv pip install "nemo-platform[nemo-agents-plugin]=={{ contract_version }}" . && \\ chmod -R a+rX /opt/uv /workspace/.venv {% else %} # The plugin owns the supported Fabric adapter and harness dependency set. 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 --prerelease=allow "nemo-agents-plugin=={{ contract_version }}" && \\ + uv pip install "nemo-platform[nemo-agents-plugin]=={{ contract_version }}" && \\ chmod -R a+rX /opt/uv /workspace/.venv {% endif %} @@ -279,6 +279,7 @@ def is_plugin_managed(path: Path) -> bool: chown -R agent:agent /workspace USER agent {% endif %} +ENV VIRTUAL_ENV=/workspace/.venv ENTRYPOINT ["sh", "-c", "exec python -m nemo_agents_plugin.fabric.server --agent-config \\\"$AGENT_CONFIG_PATH\\\" --host 0.0.0.0 --port \\\"$PORT\\\""] """ ) @@ -641,11 +642,11 @@ def render_fabric_dockerfile( def get_contract_version() -> str: - """Return the ``nemo-agents-plugin`` package version.""" + """Return the published ``nemo-platform`` package version.""" from importlib.metadata import PackageNotFoundError, version try: - return version("nemo-agents-plugin") + return version("nemo-platform") except PackageNotFoundError: return "0.0.0" diff --git a/plugins/nemo-agents/tests/unit/test_container.py b/plugins/nemo-agents/tests/unit/test_container.py index 61b430d326..57b9d999ec 100644 --- a/plugins/nemo-agents/tests/unit/test_container.py +++ b/plugins/nemo-agents/tests/unit/test_container.py @@ -94,6 +94,7 @@ def test_config_only_mode(self, agent_config: Path) -> None: assert "uv sync" not in result assert "NAT_CONFIG_FILE=/workspace/config.yaml" in result assert "ARG NAT_VERSION=1.4.0" in result + assert "ghcr.io/astral-sh/uv:0.9.14" in result def test_project_mode(self, project_dir: tuple[Path, Path]) -> None: """Project mode trusts pyproject.toml as the single source of truth. @@ -416,6 +417,7 @@ def test_starts_fabric_server(self) -> None: result = self._render() assert "ENV PORT=8000" in result + assert "ENV VIRTUAL_ENV=/workspace/.venv" in result assert "EXPOSE 8000" in result assert ( 'ENTRYPOINT ["sh", "-c", "exec python -m nemo_agents_plugin.fabric.server ' @@ -439,11 +441,13 @@ def test_config_only_mode(self, tmp_path: Path) -> None: result = render_fabric_dockerfile(agent_config) - assert 'uv pip install --prerelease=allow "nemo-agents-plugin==' in result - install_line = next(line for line in result.splitlines() if "uv pip install --prerelease=allow" in line) + assert 'uv pip install "nemo-platform[nemo-agents-plugin]==' in result + install_line = next(line for line in result.splitlines() if "uv pip install" in line) + assert "--prerelease" not in install_line assert '" .' not in install_line assert "ENV AGENT_CONFIG_PATH=/workspace/agent.yaml" in result assert "NAT_VERSION" not in result + assert "ghcr.io/astral-sh/uv:0.9.14" in result def test_renders_platform_agent_oci_labels(self, tmp_path: Path) -> None: from nemo_agents_plugin.container.metadata import extract_agent_metadata @@ -484,8 +488,9 @@ def test_project_mode_preserves_relative_config_path(self, tmp_path: Path) -> No result = render_fabric_dockerfile(agent_config, pyproject) - assert 'uv pip install --prerelease=allow "nemo-agents-plugin==' in result - install_line = next(line for line in result.splitlines() if "uv pip install --prerelease=allow" in line) + assert 'uv pip install "nemo-platform[nemo-agents-plugin]==' in result + install_line = next(line for line in result.splitlines() if "uv pip install" in line) + assert "--prerelease" not in install_line assert '" .' in install_line assert "ENV AGENT_CONFIG_PATH=/workspace/configs/agent.yaml" in result @@ -1787,7 +1792,7 @@ def test_fabric_no_build_project_mode_uses_project_root(self, package_cli, tmp_p assert output.exists() rendered = output.read_text() assert "ENV AGENT_CONFIG_PATH=/workspace/configs/agent.yaml" in rendered - install_line = next(line for line in rendered.splitlines() if "uv pip install --prerelease=allow" in line) + install_line = next(line for line in rendered.splitlines() if "uv pip install" in line) assert '" .' in install_line assert not (configs / "Dockerfile").exists() From ba674a883832b116200f57448f33bad3a1814d23 Mon Sep 17 00:00:00 2001 From: Manjesh Mogallapalli Date: Sat, 1 Aug 2026 23:25:44 -0500 Subject: [PATCH 11/13] test consolidation Signed-off-by: Manjesh Mogallapalli --- .../nemo-agents/tests/unit/test_container.py | 185 ++++---- .../unit/test_fabric_package_validation.py | 400 ++++++------------ 2 files changed, 201 insertions(+), 384 deletions(-) diff --git a/plugins/nemo-agents/tests/unit/test_container.py b/plugins/nemo-agents/tests/unit/test_container.py index 57b9d999ec..a503645e13 100644 --- a/plugins/nemo-agents/tests/unit/test_container.py +++ b/plugins/nemo-agents/tests/unit/test_container.py @@ -54,6 +54,13 @@ def project_dir(tmp_path: Path) -> tuple[Path, Path]: return config, pyproject +@pytest.fixture() +def fabric_agent_config(tmp_path: Path) -> Path: + config = tmp_path / "agent.yaml" + config.write_text("config_format: nemo-agents-spec-v1\nname: fabric-agent\n") + return config + + # --------------------------------------------------------------------------- # Render tests # --------------------------------------------------------------------------- @@ -62,28 +69,19 @@ def project_dir(tmp_path: Path) -> tuple[Path, Path]: class TestRenderNatDockerfile: """Tests for the NAT Dockerfile renderer.""" - def test_nat_params_extend_shared_contract(self) -> None: - from nemo_agents_plugin.container.template import NatRenderParams, SharedRenderParams + def test_render_params_keep_runtime_fields_separate(self) -> None: + from nemo_agents_plugin.container.template import FabricRenderParams, NatRenderParams, SharedRenderParams shared = SharedRenderParams() nat = NatRenderParams(nat_version="1.8.0") + fabric = FabricRenderParams() assert isinstance(nat, SharedRenderParams) + assert isinstance(fabric, SharedRenderParams) assert not hasattr(shared, "nat_version") assert nat.nat_version == "1.8.0" - - def test_fabric_params_extend_shared_contract(self) -> None: - from nemo_agents_plugin.container.template import FabricRenderParams, SharedRenderParams - - shared = SharedRenderParams() - fabric = FabricRenderParams() - - assert isinstance(fabric, SharedRenderParams) - assert not hasattr(shared, "nemo_agents_version") - assert not hasattr(shared, "relay_cli_version") - assert not hasattr(fabric, "nemo_agents_version") - assert not hasattr(fabric, "relay_cli_version") - assert not hasattr(fabric, "nat_version") + for field in ("nat_version", "nemo_agents_version", "relay_cli_version"): + assert not hasattr(fabric, field) def test_config_only_mode(self, agent_config: Path) -> None: from nemo_agents_plugin.container.template import render_nat_dockerfile @@ -428,18 +426,10 @@ def test_starts_fabric_server(self) -> None: class TestRenderFabricDockerfile: """Tests for the public Fabric Dockerfile renderer.""" - @staticmethod - def _write_config(directory: Path) -> Path: - agent_config = directory / "agent.yaml" - agent_config.write_text("config_format: nemo-agents-spec-v1\nname: fabric-agent\n") - return agent_config - - def test_config_only_mode(self, tmp_path: Path) -> None: + def test_config_only_mode(self, fabric_agent_config: Path) -> None: from nemo_agents_plugin.container.template import render_fabric_dockerfile - agent_config = self._write_config(tmp_path) - - result = render_fabric_dockerfile(agent_config) + result = render_fabric_dockerfile(fabric_agent_config) assert 'uv pip install "nemo-platform[nemo-agents-plugin]==' in result install_line = next(line for line in result.splitlines() if "uv pip install" in line) @@ -482,7 +472,8 @@ def test_project_mode_preserves_relative_config_path(self, tmp_path: Path) -> No configs = tmp_path / "configs" configs.mkdir() - agent_config = self._write_config(configs) + agent_config = configs / "agent.yaml" + agent_config.write_text("config_format: nemo-agents-spec-v1\nname: fabric-agent\n") pyproject = tmp_path / "pyproject.toml" pyproject.write_text('[project]\nname = "fabric-agent"\nversion = "1.0.0"\n') @@ -494,15 +485,14 @@ def test_project_mode_preserves_relative_config_path(self, tmp_path: Path) -> No assert '" .' in install_line assert "ENV AGENT_CONFIG_PATH=/workspace/configs/agent.yaml" in result - def test_custom_template_uses_fabric_context(self, tmp_path: Path) -> None: + def test_custom_template_uses_fabric_context(self, tmp_path: Path, fabric_agent_config: Path) -> None: from nemo_agents_plugin.container.template import render_fabric_dockerfile - agent_config = self._write_config(tmp_path) custom = tmp_path / "fabric.dockerfile.j2" custom.write_text("FROM {{ base_image_url }}:{{ base_image_tag }}\nENV CONFIG={{ config_file_path }}\n") result = render_fabric_dockerfile( - agent_config, + fabric_agent_config, base_image_url="custom/image", base_image_tag="custom-tag", template_path=str(custom), @@ -510,19 +500,22 @@ def test_custom_template_uses_fabric_context(self, tmp_path: Path) -> None: assert result == "FROM custom/image:custom-tag\nENV CONFIG=/workspace/agent.yaml\n" - def test_shared_environment_and_explicit_overrides(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def test_shared_environment_and_explicit_overrides( + self, + fabric_agent_config: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: from nemo_agents_plugin.container.template import render_fabric_dockerfile - agent_config = self._write_config(tmp_path) monkeypatch.setenv("NEMO_AGENTS_BASE_IMAGE_URL", "env/image") monkeypatch.setenv("NEMO_AGENTS_BASE_IMAGE_TAG", "env-tag") monkeypatch.setenv("NEMO_AGENTS_PYTHON_VERSION", "3.12") monkeypatch.setenv("NEMO_AGENTS_UV_VERSION", "0.9.0") monkeypatch.setenv("NAT_VERSION", "9.9.9") - from_environment = render_fabric_dockerfile(agent_config) + from_environment = render_fabric_dockerfile(fabric_agent_config) explicit = render_fabric_dockerfile( - agent_config, + fabric_agent_config, base_image_url="flag/image", base_image_tag="flag-tag", python_version="3.13", @@ -539,11 +532,10 @@ def test_shared_environment_and_explicit_overrides(self, tmp_path: Path, monkeyp assert "ghcr.io/astral-sh/uv:0.10.0" in explicit assert "9.9.9" not in from_environment + explicit - def test_allow_root_and_sandbox_profile(self, tmp_path: Path) -> None: + def test_allow_root_and_sandbox_profile(self, fabric_agent_config: Path) -> None: from nemo_agents_plugin.container.template import render_fabric_dockerfile from nemo_platform_plugin.sandbox import SandboxImageProfile, SandboxUser - agent_config = self._write_config(tmp_path) profile = SandboxImageProfile( name="openshell", apt_packages=("iproute2", "nftables"), @@ -554,7 +546,7 @@ def test_allow_root_and_sandbox_profile(self, tmp_path: Path) -> None: "nemo_agents_plugin.container.sandbox.discover_sandbox_profiles", return_value={"openshell": profile}, ): - result = render_fabric_dockerfile(agent_config, sandbox_runtime="openshell", allow_root=True) + result = render_fabric_dockerfile(fabric_agent_config, sandbox_runtime="openshell", allow_root=True) assert "iproute2 nftables" in result assert "groupadd --system sandbox" in result @@ -568,21 +560,25 @@ def test_config_outside_project_context_is_rejected(self, tmp_path: Path) -> Non project_dir = tmp_path / "project" agent_dir.mkdir() project_dir.mkdir() - agent_config = self._write_config(agent_dir) + agent_config = agent_dir / "agent.yaml" + agent_config.write_text("config_format: nemo-agents-spec-v1\nname: fabric-agent\n") pyproject = project_dir / "pyproject.toml" pyproject.write_text('[project]\nname = "fabric-agent"\nversion = "1.0.0"\n') with pytest.raises(ValueError, match="outside the pyproject build context"): render_fabric_dockerfile(agent_config, pyproject) - def test_unreadable_custom_template_is_reported_as_value_error(self, tmp_path: Path) -> None: + def test_unreadable_custom_template_is_reported_as_value_error( + self, + tmp_path: Path, + fabric_agent_config: Path, + ) -> None: from nemo_agents_plugin.container.template import render_fabric_dockerfile - agent_config = self._write_config(tmp_path) missing = tmp_path / "missing.dockerfile.j2" with pytest.raises(ValueError, match="failed to read --template file"): - render_fabric_dockerfile(agent_config, template_path=str(missing)) + render_fabric_dockerfile(fabric_agent_config, template_path=str(missing)) # --------------------------------------------------------------------------- @@ -658,24 +654,12 @@ def test_pyproject_overrides_name_and_version(self, project_dir: tuple[Path, Pat assert meta["agent_name"] == "test-agent" assert meta["agent_version"] == "2.3.0" - def test_fabric_config_name_used_without_pyproject(self, tmp_path: Path) -> None: + def test_fabric_config_sets_name_and_framework(self, fabric_agent_config: Path) -> None: from nemo_agents_plugin.container.metadata import extract_agent_metadata - config = tmp_path / "agent.yaml" - config.write_text("config_format: nemo-agents-spec-v1\nname: research-assistant\n") - - meta = extract_agent_metadata(config, agent_author="x") - - assert meta["agent_name"] == "research-assistant" - - def test_fabric_config_uses_platform_agent_framework(self, tmp_path: Path) -> None: - from nemo_agents_plugin.container.metadata import extract_agent_metadata - - config = tmp_path / "agent.yaml" - config.write_text("config_format: nemo-agents-spec-v1\nname: research-assistant\n") - - meta = extract_agent_metadata(config, agent_author="x") + meta = extract_agent_metadata(fabric_agent_config, agent_author="x") + assert meta["agent_name"] == "fabric-agent" assert meta["agent_framework"] == "nemo_platform_agent" def test_pyproject_name_overrides_fabric_config_name(self, tmp_path: Path) -> None: @@ -795,21 +779,7 @@ def test_description_from_pyproject(self, tmp_path: Path) -> None: meta = extract_agent_metadata(config, pyproject, agent_author="x") assert meta["description"] == "Handles math queries" - def test_description_from_fabric_config(self, tmp_path: Path) -> None: - from nemo_agents_plugin.container.metadata import extract_agent_metadata - - config = tmp_path / "agent.yaml" - config.write_text( - "config_format: nemo-agents-spec-v1\n" - "name: research-assistant\n" - "description: Researches and summarizes technical topics\n" - ) - - meta = extract_agent_metadata(config, agent_author="x") - - assert meta["description"] == "Researches and summarizes technical topics" - - def test_pyproject_description_overrides_fabric_config(self, tmp_path: Path) -> None: + def test_fabric_description_and_pyproject_override(self, tmp_path: Path) -> None: from nemo_agents_plugin.container.metadata import extract_agent_metadata config = tmp_path / "agent.yaml" @@ -821,9 +791,8 @@ def test_pyproject_description_overrides_fabric_config(self, tmp_path: Path) -> '[project]\nname = "research-assistant"\nversion = "1.0.0"\ndescription = "Project description"\n' ) - meta = extract_agent_metadata(config, pyproject, agent_author="x") - - assert meta["description"] == "Project description" + assert extract_agent_metadata(config, agent_author="x")["description"] == "Config description" + assert extract_agent_metadata(config, pyproject, agent_author="x")["description"] == "Project description" def test_description_fallback_to_workflow_type(self, agent_config: Path) -> None: from nemo_agents_plugin.container.metadata import extract_agent_metadata @@ -1199,22 +1168,20 @@ def test_detects_platform_agent_spec(self, tmp_path: Path) -> None: assert detect_agent_config_format(config) == NEMO_AGENTS_SPEC_CONFIG_FORMAT - def test_rejects_unknown_config_format(self, tmp_path: Path) -> None: + @pytest.mark.parametrize( + ("content", "error"), + [ + ("config_format: future-format-v99\n", "Unsupported agent config format"), + ("config_format: [unterminated\n", "YAML parse error"), + ], + ) + def test_rejects_invalid_config(self, tmp_path: Path, content: str, error: str) -> None: from nemo_agents_plugin.container.builder import detect_agent_config_format config = tmp_path / "agent.yaml" - config.write_text("config_format: future-format-v99\n") + config.write_text(content) - with pytest.raises(ValueError, match="Unsupported agent config format"): - detect_agent_config_format(config) - - def test_reports_yaml_parse_errors(self, tmp_path: Path) -> None: - from nemo_agents_plugin.container.builder import detect_agent_config_format - - config = tmp_path / "agent.yaml" - config.write_text("config_format: [unterminated\n") - - with pytest.raises(ValueError, match="YAML parse error"): + with pytest.raises(ValueError, match=error): detect_agent_config_format(config) @@ -1694,11 +1661,11 @@ def test_no_build_renders_dockerfile_and_ignore(self, package_cli, agent_config: mock_build.assert_not_called() mock_push.assert_not_called() - def test_fabric_no_build_renders_fabric_dockerfile_and_ignore(self, package_cli, tmp_path: Path) -> None: + def test_fabric_no_build_renders_fabric_dockerfile_and_ignore( + self, package_cli, fabric_agent_config: Path, tmp_path: Path + ) -> None: """Fabric ``--no-build`` renders without invoking either image builder.""" app, runner = package_cli - agent_config = tmp_path / "agent.yaml" - agent_config.write_text("config_format: nemo-agents-spec-v1\nname: fabric-agent\n") output = tmp_path / "Dockerfile.fabric" with ( @@ -1711,7 +1678,7 @@ def test_fabric_no_build_renders_fabric_dockerfile_and_ignore(self, package_cli, [ "package", "--agent", - str(agent_config), + str(fabric_agent_config), "--output", str(output), "--no-build", @@ -1729,11 +1696,11 @@ def test_fabric_no_build_renders_fabric_dockerfile_and_ignore(self, package_cli, mock_nat_build.assert_not_called() mock_push.assert_not_called() - def test_fabric_no_build_forwards_shared_flags(self, package_cli, tmp_path: Path) -> None: + def test_fabric_no_build_forwards_shared_flags( + self, package_cli, fabric_agent_config: Path, tmp_path: Path + ) -> None: """Fabric render-only mode honors shared CLI flags without NAT arguments.""" app, runner = package_cli - agent_config = tmp_path / "agent.yaml" - agent_config.write_text("config_format: nemo-agents-spec-v1\nname: fabric-agent\n") output = tmp_path / "Dockerfile.fabric" result = runner.invoke( @@ -1741,7 +1708,7 @@ def test_fabric_no_build_forwards_shared_flags(self, package_cli, tmp_path: Path [ "package", "--agent", - str(agent_config), + str(fabric_agent_config), "--output", str(output), "--base-image-url", @@ -1980,11 +1947,9 @@ def test_shared_package_flags_override_environment( assert kwargs["python_version"] == "3.13" assert kwargs["uv_version"] == "0.10.0" - def test_fabric_config_routes_only_to_fabric_builder(self, package_cli, tmp_path: Path) -> None: + def test_fabric_config_routes_only_to_fabric_builder(self, package_cli, fabric_agent_config: Path) -> None: """Fabric configs select the Fabric builder without NAT-only arguments.""" app, runner = package_cli - agent_config = tmp_path / "agent.yaml" - agent_config.write_text("config_format: nemo-agents-spec-v1\nname: fabric-agent\n") with ( patch("nemo_agents_plugin.container.builder.build_fabric_agent_image") as mock_fabric_build, @@ -1996,7 +1961,7 @@ def test_fabric_config_routes_only_to_fabric_builder(self, package_cli, tmp_path [ "package", "--agent", - str(agent_config), + str(fabric_agent_config), "--tag", "fabric-agent:dev", ], @@ -2006,51 +1971,45 @@ def test_fabric_config_routes_only_to_fabric_builder(self, package_cli, tmp_path assert "Image ready: fabric-agent:dev" in result.stdout mock_fabric_build.assert_called_once() mock_nat_build.assert_not_called() - assert mock_fabric_build.call_args.args == (agent_config,) + assert mock_fabric_build.call_args.args == (fabric_agent_config,) assert mock_fabric_build.call_args.kwargs["tag"] == "fabric-agent:dev" assert "nat_version" not in mock_fabric_build.call_args.kwargs - def test_fabric_validation_error_is_reported_cleanly(self, package_cli, tmp_path: Path) -> None: + def test_fabric_validation_error_is_reported_cleanly(self, package_cli, fabric_agent_config: Path) -> None: """Fabric package validation failures are presented as CLI errors.""" from nemo_agents_plugin.container.fabric_validator import FabricPackageValidationError app, runner = package_cli - agent_config = tmp_path / "agent.yaml" - agent_config.write_text("config_format: nemo-agents-spec-v1\nname: fabric-agent\n") with patch("nemo_agents_plugin.container.builder.build_fabric_agent_image") as mock_fabric_build: mock_fabric_build.side_effect = FabricPackageValidationError( "Fabric package validation failed: invalid harness settings" ) - result = runner.invoke(app, ["package", "--agent", str(agent_config)]) + result = runner.invoke(app, ["package", "--agent", str(fabric_agent_config)]) assert result.exit_code == 1 assert "Error: Fabric package validation failed: invalid harness settings" in (result.stderr or result.stdout) assert result.exception is not None - def test_fabric_skip_validation_is_forwarded(self, package_cli, tmp_path: Path) -> None: + def test_fabric_skip_validation_is_forwarded(self, package_cli, fabric_agent_config: Path) -> None: """The shared skip flag reaches the Fabric builder.""" app, runner = package_cli - agent_config = tmp_path / "agent.yaml" - agent_config.write_text("config_format: nemo-agents-spec-v1\nname: fabric-agent\n") with patch("nemo_agents_plugin.container.builder.build_fabric_agent_image") as mock_fabric_build: mock_fabric_build.return_value = "fabric-agent:dev" result = runner.invoke( app, - ["package", "--agent", str(agent_config), "--skip-validation"], + ["package", "--agent", str(fabric_agent_config), "--skip-validation"], ) assert result.exit_code == 0, result.stdout assert mock_fabric_build.call_args.kwargs["skip_validation"] is True def test_fabric_config_ignores_ambient_nat_version( - self, package_cli, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + self, package_cli, fabric_agent_config: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """A shell-level NAT_VERSION must not affect Fabric packaging.""" app, runner = package_cli - agent_config = tmp_path / "agent.yaml" - agent_config.write_text("config_format: nemo-agents-spec-v1\nname: fabric-agent\n") monkeypatch.setenv("NAT_VERSION", "1.8.0") with ( @@ -2058,18 +2017,16 @@ def test_fabric_config_ignores_ambient_nat_version( patch("nemo_agents_plugin.container.builder.build_nat_agent_image") as mock_nat_build, ): mock_fabric_build.return_value = "fabric-agent:dev" - result = runner.invoke(app, ["package", "--agent", str(agent_config)]) + result = runner.invoke(app, ["package", "--agent", str(fabric_agent_config)]) assert result.exit_code == 0, result.stdout mock_fabric_build.assert_called_once() mock_nat_build.assert_not_called() assert "nat_version" not in mock_fabric_build.call_args.kwargs - def test_fabric_config_rejects_explicit_nat_version(self, package_cli, tmp_path: Path) -> None: + def test_fabric_config_rejects_explicit_nat_version(self, package_cli, fabric_agent_config: Path) -> None: """An explicit --nat-version remains an error for Fabric packaging.""" app, runner = package_cli - agent_config = tmp_path / "agent.yaml" - agent_config.write_text("config_format: nemo-agents-spec-v1\nname: fabric-agent\n") with ( patch("nemo_agents_plugin.container.builder.build_fabric_agent_image") as mock_fabric_build, @@ -2077,7 +2034,7 @@ def test_fabric_config_rejects_explicit_nat_version(self, package_cli, tmp_path: ): result = runner.invoke( app, - ["package", "--agent", str(agent_config), "--nat-version", "1.8.0"], + ["package", "--agent", str(fabric_agent_config), "--nat-version", "1.8.0"], ) assert result.exit_code == 1 diff --git a/plugins/nemo-agents/tests/unit/test_fabric_package_validation.py b/plugins/nemo-agents/tests/unit/test_fabric_package_validation.py index 3bbd5f9ff8..cb0296989a 100644 --- a/plugins/nemo-agents/tests/unit/test_fabric_package_validation.py +++ b/plugins/nemo-agents/tests/unit/test_fabric_package_validation.py @@ -6,12 +6,16 @@ from __future__ import annotations from pathlib import Path -from typing import Any +from unittest.mock import AsyncMock, MagicMock +import nemo_agents_plugin.container.builder as builder import nemo_agents_plugin.container.fabric_validator as fabric_validator +import nemo_agents_plugin.container.metadata as metadata +import nemo_agents_plugin.container.template as template import pytest import typer from nemo_agents_plugin.agent_config import AgentConfig +from nemo_agents_plugin.container.builder import build_fabric_agent_image from nemo_agents_plugin.container.fabric_validator import ( FabricPackageArtifactError, FabricPackageValidationError, @@ -51,6 +55,12 @@ def _write_package_config(path: Path, *skill_paths: str) -> Path: return path +def _write_pyproject(root: Path) -> Path: + pyproject = root / "pyproject.toml" + pyproject.write_text('[project]\nname = "fabric-agent"\nversion = "1.0.0"\n') + return pyproject + + def _image_metadata() -> dict[str, str]: return { "agent_name": "fabric-agent", @@ -66,6 +76,11 @@ def _image_metadata() -> dict[str, str]: } +def _stub_fabric_render(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(metadata, "extract_agent_metadata", MagicMock(return_value=_image_metadata())) + monkeypatch.setattr(template, "render_fabric_dockerfile", MagicMock(return_value="FROM scratch\n")) + + @pytest.mark.asyncio class TestValidateFabricAgentPackage: async def test_loads_translates_plans_and_validates_artifacts( @@ -74,20 +89,10 @@ async def test_loads_translates_plans_and_validates_artifacts( agent_config_path = _write_package_config(tmp_path / "configs" / "agent.yaml", "../skills/review") _skill(tmp_path / "skills" / "review") translated_config = object() - calls: dict[str, Any] = {} - - def _translate(config: AgentConfig) -> object: - calls["translated_agent_config"] = config - return translated_config - - async def _plan(config: object, *, base_dir: Path, fabric: Any | None = None) -> object: - calls["planned_config"] = config - calls["base_dir"] = base_dir - calls["fabric"] = fabric - return {"plan": "ok"} - - monkeypatch.setattr(fabric_validator, "translate_agent_config", _translate) - monkeypatch.setattr(fabric_validator, "plan_fabric_config", _plan) + translate = MagicMock(return_value=translated_config) + plan = AsyncMock(return_value={"plan": "ok"}) + monkeypatch.setattr(fabric_validator, "translate_agent_config", translate) + monkeypatch.setattr(fabric_validator, "plan_fabric_config", plan) result = await validate_fabric_agent_package( agent_config_path, @@ -95,40 +100,15 @@ async def _plan(config: object, *, base_dir: Path, fabric: Any | None = None) -> fabric="fabric-client", ) - assert result.agent_config is calls["translated_agent_config"] + translate.assert_called_once_with(result.agent_config) assert result.fabric_config is translated_config assert result.plan == {"plan": "ok"} - assert calls["planned_config"] is translated_config - assert calls["base_dir"] == agent_config_path.parent.resolve() - assert calls["fabric"] == "fabric-client" - - async def test_runs_plan_without_doctor(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - agent_config_path = _write_package_config(tmp_path / "agent.yaml") - translated_config = object() - - class _PlanOnlyFabric: - def __init__(self) -> None: - self.plan_calls: list[tuple[object, Path]] = [] - - def plan(self, config: object, *, base_dir: Path) -> object: - self.plan_calls.append((config, base_dir)) - return {"plan": "ok"} - - async def doctor(self, config: object, *, base_dir: Path) -> object: - raise AssertionError(f"doctor must not run for package validation: {config}, {base_dir}") - - fabric = _PlanOnlyFabric() - monkeypatch.setattr(fabric_validator, "translate_agent_config", lambda config: translated_config) - - result = await validate_fabric_agent_package( - agent_config_path, - context_dir=tmp_path, - fabric=fabric, + plan.assert_awaited_once_with( + translated_config, + base_dir=agent_config_path.parent.resolve(), + fabric="fabric-client", ) - assert result.plan == {"plan": "ok"} - assert fabric.plan_calls == [(translated_config, agent_config_path.parent.resolve())] - async def test_wraps_schema_error(self, tmp_path: Path) -> None: invalid_config = tmp_path / "invalid.yaml" invalid_config.write_text("name: missing-required-fields\n") @@ -138,36 +118,29 @@ async def test_wraps_schema_error(self, tmp_path: Path) -> None: async def test_wraps_translation_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: valid_config = _write_package_config(tmp_path / "agent.yaml") - - def _translation_failure(config: AgentConfig) -> object: - del config - raise fabric_validator.FabricTranslationError("unsupported harness") - - monkeypatch.setattr(fabric_validator, "translate_agent_config", _translation_failure) + monkeypatch.setattr( + fabric_validator, + "translate_agent_config", + MagicMock(side_effect=fabric_validator.FabricTranslationError("unsupported harness")), + ) with pytest.raises(FabricPackageValidationError, match="unsupported harness"): await validate_fabric_agent_package(valid_config, context_dir=tmp_path) async def test_wraps_plan_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: valid_config = _write_package_config(tmp_path / "agent.yaml") monkeypatch.setattr(fabric_validator, "translate_agent_config", lambda config: object()) - - async def _plan_failure(config: object, *, base_dir: Path, fabric: Any | None = None) -> object: - del config, base_dir, fabric - raise fabric_validator.FabricValidationError("Fabric plan failed: invalid config") - - monkeypatch.setattr(fabric_validator, "plan_fabric_config", _plan_failure) + monkeypatch.setattr( + fabric_validator, + "plan_fabric_config", + AsyncMock(side_effect=fabric_validator.FabricValidationError("Fabric plan failed: invalid config")), + ) with pytest.raises(FabricPackageValidationError, match="Fabric plan failed: invalid config"): await validate_fabric_agent_package(valid_config, context_dir=tmp_path) async def test_surfaces_artifact_validation_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: agent_config_path = _write_package_config(tmp_path / "agent.yaml", "skills/missing") monkeypatch.setattr(fabric_validator, "translate_agent_config", lambda config: object()) - - async def _plan(config: object, *, base_dir: Path, fabric: Any | None = None) -> object: - del config, base_dir, fabric - return {"plan": "ok"} - - monkeypatch.setattr(fabric_validator, "plan_fabric_config", _plan) + monkeypatch.setattr(fabric_validator, "plan_fabric_config", AsyncMock(return_value={"plan": "ok"})) with pytest.raises(FabricPackageArtifactError, match="skills/missing"): await validate_fabric_agent_package(agent_config_path, context_dir=tmp_path) @@ -176,62 +149,34 @@ async def _plan(config: object, *, base_dir: Path, fabric: Any | None = None) -> class TestFabricBuilderValidationHook: @pytest.fixture(autouse=True) def _stub_docker_build(self, monkeypatch: pytest.MonkeyPatch) -> None: - import nemo_agents_plugin.container.builder as builder - monkeypatch.setattr(builder, "docker_build", lambda **kwargs: str(kwargs["tag"])) - def test_validates_with_selected_build_context(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - from nemo_agents_plugin.container.builder import build_fabric_agent_image - - agent_config_path = _write_package_config(tmp_path / "configs" / "agent.yaml") - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text('[project]\nname = "fabric-agent"\nversion = "1.0.0"\n') - calls: list[tuple[Path, Path]] = [] - - async def _validate(agent_config: Path, *, context_dir: Path) -> object: - calls.append((agent_config, context_dir)) - return object() - - monkeypatch.setattr(fabric_validator, "validate_fabric_agent_package", _validate) - - build_fabric_agent_image(agent_config_path, pyproject=pyproject) - - assert calls == [(agent_config_path, tmp_path.resolve())] - - def test_uses_agent_config_directory_without_pyproject( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + @pytest.mark.parametrize("project_mode", [False, True], ids=["config-only", "project"]) + def test_validates_with_selected_build_context( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + project_mode: bool, ) -> None: - from nemo_agents_plugin.container.builder import build_fabric_agent_image - agent_config_path = _write_package_config(tmp_path / "configs" / "agent.yaml") - calls: list[tuple[Path, Path]] = [] - - async def _validate(agent_config: Path, *, context_dir: Path) -> object: - calls.append((agent_config, context_dir)) - return object() - - monkeypatch.setattr(fabric_validator, "validate_fabric_agent_package", _validate) + pyproject = _write_pyproject(tmp_path) if project_mode else None + validate = AsyncMock() + monkeypatch.setattr(fabric_validator, "validate_fabric_agent_package", validate) - build_fabric_agent_image(agent_config_path) + build_fabric_agent_image(agent_config_path, pyproject=pyproject) - assert calls == [(agent_config_path, agent_config_path.parent.resolve())] + context_dir = tmp_path if project_mode else agent_config_path.parent + validate.assert_awaited_once_with(agent_config_path, context_dir=context_dir.resolve()) def test_skip_validation_bypasses_hook(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - from nemo_agents_plugin.container.builder import build_fabric_agent_image - agent_config_path = _write_package_config(tmp_path / "agent.yaml") - - async def _unexpected_validation(agent_config: Path, *, context_dir: Path) -> object: - raise AssertionError(f"unexpected validation for {agent_config} in {context_dir}") - - monkeypatch.setattr(fabric_validator, "validate_fabric_agent_package", _unexpected_validation) + validate = AsyncMock() + monkeypatch.setattr(fabric_validator, "validate_fabric_agent_package", validate) build_fabric_agent_image(agent_config_path, skip_validation=True) + validate.assert_not_awaited() def test_resolves_shared_build_settings(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - from nemo_agents_plugin.container import metadata, template - from nemo_agents_plugin.container.builder import build_fabric_agent_image - monkeypatch.setattr(metadata, "extract_agent_metadata", lambda *args, **kwargs: _image_metadata()) agent_config_path = _write_package_config(tmp_path / "agent.yaml") @@ -262,31 +207,13 @@ def _resolve(name: str, explicit: str | None = None) -> str: ] def test_extracts_metadata_and_derives_default_tag(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - import nemo_agents_plugin.container.builder as builder - import nemo_agents_plugin.container.metadata as metadata - import nemo_agents_plugin.container.template as template - agent_config_path = _write_package_config(tmp_path / "agent.yaml") - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text('[project]\nname = "fabric-agent"\nversion = "1.0.0"\n') + pyproject = _write_pyproject(tmp_path) extracted_meta = _image_metadata() - metadata_calls: list[dict[str, object]] = [] - tag_calls: list[dict[str, str]] = [] - - def _extract( - agent_config: Path, - project: Path | None, - **kwargs: object, - ) -> dict[str, str]: - metadata_calls.append({"agent_config": agent_config, "pyproject": project, **kwargs}) - return extracted_meta - - def _default_tag(meta: dict[str, str]) -> str: - tag_calls.append(meta) - return "fabric-agent-abc123:1.0.0" - - monkeypatch.setattr(metadata, "extract_agent_metadata", _extract) - monkeypatch.setattr(builder, "_default_tag_from_meta", _default_tag) + extract = MagicMock(return_value=extracted_meta) + default_tag = MagicMock(return_value="fabric-agent-abc123:1.0.0") + monkeypatch.setattr(metadata, "extract_agent_metadata", extract) + monkeypatch.setattr(builder, "_default_tag_from_meta", default_tag) monkeypatch.setattr(template, "get_contract_version", lambda: "1.0.0") result = builder.build_fabric_agent_image( @@ -301,28 +228,25 @@ def _default_tag(meta: dict[str, str]) -> str: skip_validation=True, ) - assert metadata_calls == [ - { - "agent_config": agent_config_path, - "pyproject": pyproject, - "agent_version": "2.0.0", - "agent_author": "Agent Author", - "build_env": { - "agent_framework": "nemo_platform_agent", - "contract_version": "1.0.0", - "nemo_relay_cli_version": "0.6.0", - "base_image_url": "registry.example/base", - "base_image_tag": "release", - "python_version": "3.13", - "uv_version": "0.8.15", - }, - } - ] - assert tag_calls == [extracted_meta] + extract.assert_called_once_with( + agent_config_path, + pyproject, + agent_version="2.0.0", + agent_author="Agent Author", + build_env={ + "agent_framework": "nemo_platform_agent", + "contract_version": "1.0.0", + "nemo_relay_cli_version": "0.6.0", + "base_image_url": "registry.example/base", + "base_image_tag": "release", + "python_version": "3.13", + "uv_version": "0.8.15", + }, + ) + default_tag.assert_called_once_with(extracted_meta) assert result == "fabric-agent-abc123:1.0.0" def test_default_tag_uses_fabric_name_and_runtime_identity(self, tmp_path: Path) -> None: - from nemo_agents_plugin.container.builder import build_fabric_agent_image from nemo_agents_plugin.container.metadata import ( NEMO_PLATFORM_AGENT_FRAMEWORK, extract_agent_metadata, @@ -363,32 +287,21 @@ def test_default_tag_uses_fabric_name_and_runtime_identity(self, tmp_path: Path) assert result == f"packaged-agent-{expected_metadata['agent_id']}:2.0.0" def test_renders_generated_dockerfile(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - import nemo_agents_plugin.container.builder as builder - from nemo_agents_plugin.container import metadata, template - from nemo_agents_plugin.container.builder import build_fabric_agent_image - agent_config_path = _write_package_config(tmp_path / "configs" / "agent.yaml") - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text('[project]\nname = "fabric-agent"\nversion = "1.0.0"\n') + pyproject = _write_pyproject(tmp_path) image_metadata = _image_metadata() - render_calls: list[tuple[tuple[object, ...], dict[str, object]]] = [] - build_calls: list[dict[str, object]] = [] - - monkeypatch.setattr(metadata, "extract_agent_metadata", lambda *args, **kwargs: image_metadata) - - def _render(*args: object, **kwargs: object) -> str: - render_calls.append((args, kwargs)) - return "FROM scratch\n" + render = MagicMock(return_value="FROM scratch\n") def _build(**kwargs: object) -> str: dockerfile = kwargs["dockerfile"] assert isinstance(dockerfile, Path) assert dockerfile.read_text() == "FROM scratch\n" - build_calls.append(kwargs) return "fabric-agent:test" - monkeypatch.setattr(template, "render_fabric_dockerfile", _render) - monkeypatch.setattr(builder, "docker_build", _build) + docker_build = MagicMock(side_effect=_build) + monkeypatch.setattr(metadata, "extract_agent_metadata", lambda *args, **kwargs: image_metadata) + monkeypatch.setattr(template, "render_fabric_dockerfile", render) + monkeypatch.setattr(builder, "docker_build", docker_build) result = build_fabric_agent_image( agent_config_path, @@ -409,54 +322,42 @@ def _build(**kwargs: object) -> str: ) assert result == "fabric-agent:test" - assert render_calls == [ - ( - (agent_config_path, pyproject), - { - "base_image_url": "registry.example/base", - "base_image_tag": "release", - "python_version": "3.13", - "uv_version": "0.8.15", - "allow_root": True, - "sandbox_runtime": "openshell", - "agent_version": "2.0.0", - "agent_author": "Agent Author", - "template_path": "Dockerfile.fabric.j2", - "metadata": image_metadata, - }, - ) - ] - assert build_calls == [ - { - "context_dir": tmp_path.resolve(), - "dockerfile": tmp_path / "Dockerfile.generated", - "tag": "fabric-agent:test", - "build_args": { - "BASE_IMAGE_URL": "registry.example/base", - "BASE_IMAGE_TAG": "release", - "PYTHON_VERSION": "3.13", - }, - "platforms": ["linux/amd64"], - "push": True, - } - ] + render.assert_called_once_with( + agent_config_path, + pyproject, + base_image_url="registry.example/base", + base_image_tag="release", + python_version="3.13", + uv_version="0.8.15", + allow_root=True, + sandbox_runtime="openshell", + agent_version="2.0.0", + agent_author="Agent Author", + template_path="Dockerfile.fabric.j2", + metadata=image_metadata, + ) + docker_build.assert_called_once_with( + context_dir=tmp_path.resolve(), + dockerfile=tmp_path / "Dockerfile.generated", + tag="fabric-agent:test", + build_args={ + "BASE_IMAGE_URL": "registry.example/base", + "BASE_IMAGE_TAG": "release", + "PYTHON_VERSION": "3.13", + }, + platforms=["linux/amd64"], + push=True, + ) assert not (tmp_path / "Dockerfile.generated").exists() assert not (tmp_path / ".dockerignore").exists() def test_packages_nested_config_and_skill(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - import nemo_agents_plugin.container.builder as builder - agent_config_path = _write_package_config(tmp_path / "configs" / "agent.yaml", "../skills/review") skill = _skill(tmp_path / "skills" / "review") - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text('[project]\nname = "fabric-agent"\nversion = "1.0.0"\n') + pyproject = _write_pyproject(tmp_path) monkeypatch.setattr(fabric_validator, "translate_agent_config", lambda config: object()) - async def _plan(config: object, *, base_dir: Path, fabric: Any | None = None) -> object: - del config, base_dir, fabric - return {"plan": "ok"} - def _build(**kwargs: object) -> str: dockerfile = kwargs["dockerfile"] assert isinstance(dockerfile, Path) @@ -466,7 +367,7 @@ def _build(**kwargs: object) -> str: assert (skill / "SKILL.md").is_file() return "fabric-agent:test" - monkeypatch.setattr(fabric_validator, "plan_fabric_config", _plan) + monkeypatch.setattr(fabric_validator, "plan_fabric_config", AsyncMock(return_value={"plan": "ok"})) monkeypatch.setattr(builder, "docker_build", _build) result = builder.build_fabric_agent_image( @@ -480,16 +381,11 @@ def _build(**kwargs: object) -> str: assert not (tmp_path / ".dockerignore").exists() def test_preserves_user_owned_dockerignore(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - from nemo_agents_plugin.container import metadata, template - from nemo_agents_plugin.container.builder import build_fabric_agent_image - agent_config_path = _write_package_config(tmp_path / "agent.yaml") dockerignore = tmp_path / ".dockerignore" user_content = "custom-output/\n" dockerignore.write_text(user_content) - - monkeypatch.setattr(metadata, "extract_agent_metadata", lambda *args, **kwargs: _image_metadata()) - monkeypatch.setattr(template, "render_fabric_dockerfile", lambda *args, **kwargs: "FROM scratch\n") + _stub_fabric_render(monkeypatch) build_fabric_agent_image( agent_config_path, @@ -501,12 +397,8 @@ def test_preserves_user_owned_dockerignore(self, tmp_path: Path, monkeypatch: py assert not (tmp_path / "Dockerfile.generated").exists() def test_skips_dockerignore_generation_when_disabled(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - from nemo_agents_plugin.container import metadata, template - from nemo_agents_plugin.container.builder import build_fabric_agent_image - agent_config_path = _write_package_config(tmp_path / "agent.yaml") - monkeypatch.setattr(metadata, "extract_agent_metadata", lambda *args, **kwargs: _image_metadata()) - monkeypatch.setattr(template, "render_fabric_dockerfile", lambda *args, **kwargs: "FROM scratch\n") + _stub_fabric_render(monkeypatch) build_fabric_agent_image( agent_config_path, @@ -518,18 +410,9 @@ def test_skips_dockerignore_generation_when_disabled(self, tmp_path: Path, monke assert not (tmp_path / ".dockerignore").exists() def test_cleans_transient_files_when_build_fails(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - import nemo_agents_plugin.container.builder as builder - from nemo_agents_plugin.container import metadata, template - agent_config_path = _write_package_config(tmp_path / "agent.yaml") - monkeypatch.setattr(metadata, "extract_agent_metadata", lambda *args, **kwargs: _image_metadata()) - monkeypatch.setattr(template, "render_fabric_dockerfile", lambda *args, **kwargs: "FROM scratch\n") - - def _failed_build(**kwargs: object) -> str: - del kwargs - raise RuntimeError("docker build failed") - - monkeypatch.setattr(builder, "docker_build", _failed_build) + _stub_fabric_render(monkeypatch) + monkeypatch.setattr(builder, "docker_build", MagicMock(side_effect=RuntimeError("docker build failed"))) with pytest.raises(RuntimeError, match="docker build failed"): builder.build_fabric_agent_image( @@ -542,15 +425,12 @@ def _failed_build(**kwargs: object) -> str: assert not (tmp_path / ".dockerignore").exists() def test_preserves_preexisting_managed_dockerignore(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - from nemo_agents_plugin.container import metadata, template - from nemo_agents_plugin.container.builder import build_fabric_agent_image from nemo_agents_plugin.container.template import DOCKERIGNORE_SENTINEL agent_config_path = _write_package_config(tmp_path / "agent.yaml") dockerignore = tmp_path / ".dockerignore" dockerignore.write_text(f"{DOCKERIGNORE_SENTINEL}\n# committed file\n") - monkeypatch.setattr(metadata, "extract_agent_metadata", lambda *args, **kwargs: _image_metadata()) - monkeypatch.setattr(template, "render_fabric_dockerfile", lambda *args, **kwargs: "FROM scratch\n") + _stub_fabric_render(monkeypatch) build_fabric_agent_image( agent_config_path, @@ -562,15 +442,11 @@ def test_preserves_preexisting_managed_dockerignore(self, tmp_path: Path, monkey assert dockerignore.read_text().splitlines()[0] == DOCKERIGNORE_SENTINEL def test_refuses_to_overwrite_generated_dockerfile(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - from nemo_agents_plugin.container import metadata, template - from nemo_agents_plugin.container.builder import build_fabric_agent_image - agent_config_path = _write_package_config(tmp_path / "agent.yaml") generated = tmp_path / "Dockerfile.generated" user_content = "FROM user-owned-image\n" generated.write_text(user_content) - monkeypatch.setattr(metadata, "extract_agent_metadata", lambda *args, **kwargs: _image_metadata()) - monkeypatch.setattr(template, "render_fabric_dockerfile", lambda *args, **kwargs: "FROM scratch\n") + _stub_fabric_render(monkeypatch) with pytest.raises(typer.Exit): build_fabric_agent_image( @@ -582,29 +458,15 @@ def test_refuses_to_overwrite_generated_dockerfile(self, tmp_path: Path, monkeyp assert generated.read_text() == user_content def test_builds_with_user_provided_dockerfile(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - import nemo_agents_plugin.container.builder as builder - import nemo_agents_plugin.container.metadata as metadata - import nemo_agents_plugin.container.template as template - agent_config_path = _write_package_config(tmp_path / "configs" / "agent.yaml") - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text('[project]\nname = "fabric-agent"\nversion = "1.0.0"\n') + pyproject = _write_pyproject(tmp_path) dockerfile = tmp_path / "Dockerfile.custom" dockerfile.write_text("FROM scratch\n") - build_calls: list[dict[str, object]] = [] - monkeypatch.setattr(metadata, "extract_agent_metadata", lambda *args, **kwargs: _image_metadata()) - - def _unexpected_render(*args: object, **kwargs: object) -> str: - raise AssertionError(f"unexpected Fabric Dockerfile render: {args}, {kwargs}") - - monkeypatch.setattr(template, "render_fabric_dockerfile", _unexpected_render) - - def _build(**kwargs: object) -> str: - build_calls.append(kwargs) - return "fabric-agent:test" - - monkeypatch.setattr(builder, "docker_build", _build) + render = MagicMock() + docker_build = MagicMock(return_value="fabric-agent:test") + monkeypatch.setattr(template, "render_fabric_dockerfile", render) + monkeypatch.setattr(builder, "docker_build", docker_build) result = builder.build_fabric_agent_image( agent_config_path, @@ -621,21 +483,19 @@ def _build(**kwargs: object) -> str: ) assert result == "fabric-agent:test" - assert build_calls == [ - { - "context_dir": tmp_path.resolve(), - "dockerfile": dockerfile, - "tag": "fabric-agent:test", - "build_args": { - "BASE_IMAGE_URL": "registry.example/base", - "BASE_IMAGE_TAG": "release", - "PYTHON_VERSION": "3.13", - }, - "platforms": ["linux/amd64"], - "push": True, - } - ] - assert "NAT_VERSION" not in build_calls[0]["build_args"] + render.assert_not_called() + docker_build.assert_called_once_with( + context_dir=tmp_path.resolve(), + dockerfile=dockerfile, + tag="fabric-agent:test", + build_args={ + "BASE_IMAGE_URL": "registry.example/base", + "BASE_IMAGE_TAG": "release", + "PYTHON_VERSION": "3.13", + }, + platforms=["linux/amd64"], + push=True, + ) assert not (tmp_path / "Dockerfile.generated").exists() assert not (tmp_path / ".dockerignore").exists() From b5438b33744e055dbdd4fee7ba1eda9ab2429918 Mon Sep 17 00:00:00 2001 From: Manjesh Mogallapalli Date: Sat, 1 Aug 2026 23:48:55 -0500 Subject: [PATCH 12/13] cr feedback Signed-off-by: Manjesh Mogallapalli --- .../nemo_agents_plugin/container/builder.py | 9 ++++- .../nemo_agents_plugin/container/template.py | 18 +++++++-- .../nemo-agents/tests/unit/test_container.py | 40 ++++++++++++++++--- 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py b/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py index a8dad59040..a907e36d81 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py @@ -380,7 +380,14 @@ def build_fabric_agent_image( def detect_agent_config_format(agent_config: Path) -> str: try: - data = yaml.safe_load(agent_config.read_text(encoding="utf-8")) + raw = agent_config.read_text(encoding="utf-8") + except OSError as exc: + raise ValueError(f"Unable to read config file: {exc}") from exc + except UnicodeDecodeError as exc: + raise ValueError(f"Config file is not valid UTF-8: {exc}") from exc + + try: + data = yaml.safe_load(raw) except yaml.YAMLError as exc: raise ValueError(f"YAML parse error in agent config {agent_config}: {exc}") from exc diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/container/template.py b/plugins/nemo-agents/src/nemo_agents_plugin/container/template.py index 624d186e75..ce754aba5f 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/container/template.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/container/template.py @@ -90,6 +90,8 @@ def is_plugin_managed(path: Path) -> bool: } PINNED_NEMO_RELAY_CLI_VERSION = "0.6.0" +PINNED_NEMO_RELAY_INSTALLER_COMMIT = "40c5990361afc26ae8b901ff1f49c2b03ddd9ede" +PINNED_NEMO_RELAY_INSTALLER_SHA256 = "ba2585a32e568643819992fa66b750004328351fce422b979d8c11cfc8bbfadb" # -- Jinja2 template -------------------------------------------------------- @@ -213,10 +215,11 @@ def is_plugin_managed(path: Path) -> bool: update-ca-certificates && \\ rm -rf /var/lib/apt/lists/* -# Claude and Codex Relay integration launches this external CLI. The installer -# verifies the checksum for the pinned release before placing it on the global -# runtime PATH. -RUN curl -fsSL https://raw.githubusercontent.com/NVIDIA/NeMo-Relay/main/install.sh -o /tmp/install-nemo-relay.sh && \\ +# Claude and Codex Relay integration launches this external CLI. Authenticate +# the immutable installer before root execution; it separately verifies the +# pinned release binary before placing it on the global runtime PATH. +RUN curl -fsSL https://raw.githubusercontent.com/NVIDIA/NeMo-Relay/{{ pinned_nemo_relay_installer_commit }}/install.sh -o /tmp/install-nemo-relay.sh && \\ + echo "{{ pinned_nemo_relay_installer_sha256 }} /tmp/install-nemo-relay.sh" | sha256sum -c - && \\ NEMO_RELAY_VERSION={{ pinned_nemo_relay_cli_version }} sh /tmp/install-nemo-relay.sh --install-dir /usr/local/bin && \\ rm /tmp/install-nemo-relay.sh && \\ nemo-relay --version @@ -421,6 +424,8 @@ def _jinja_env() -> jinja2.Environment: ) env.filters["dockerfile_escape"] = _dockerfile_escape env.globals["pinned_nemo_relay_cli_version"] = PINNED_NEMO_RELAY_CLI_VERSION + env.globals["pinned_nemo_relay_installer_commit"] = PINNED_NEMO_RELAY_INSTALLER_COMMIT + env.globals["pinned_nemo_relay_installer_sha256"] = PINNED_NEMO_RELAY_INSTALLER_SHA256 return env @@ -627,6 +632,11 @@ def render_fabric_dockerfile( agent_author=agent_author, metadata=metadata, ) + if shared.contract_version == "0.0.0": + raise ValueError( + "Unable to resolve the installed nemo-platform contract version; " + "Fabric packaging requires an installed release version." + ) params = FabricRenderParams(**{f.name: getattr(shared, f.name) for f in fields(shared)}) if template_path: diff --git a/plugins/nemo-agents/tests/unit/test_container.py b/plugins/nemo-agents/tests/unit/test_container.py index a503645e13..c77f859425 100644 --- a/plugins/nemo-agents/tests/unit/test_container.py +++ b/plugins/nemo-agents/tests/unit/test_container.py @@ -395,10 +395,17 @@ def _render(**overrides: object) -> str: return _jinja_env().from_string(FABRIC_DOCKERFILE_TEMPLATE).render(**asdict(params)) def test_installs_pinned_relay_cli_globally(self) -> None: - from nemo_agents_plugin.container.template import PINNED_NEMO_RELAY_CLI_VERSION + from nemo_agents_plugin.container.template import ( + PINNED_NEMO_RELAY_CLI_VERSION, + PINNED_NEMO_RELAY_INSTALLER_COMMIT, + PINNED_NEMO_RELAY_INSTALLER_SHA256, + ) result = self._render() + assert f"NVIDIA/NeMo-Relay/{PINNED_NEMO_RELAY_INSTALLER_COMMIT}/install.sh" in result + assert f'echo "{PINNED_NEMO_RELAY_INSTALLER_SHA256} /tmp/install-nemo-relay.sh"' in result + assert result.index("sha256sum -c -") < result.index("NEMO_RELAY_VERSION=") assert f"NEMO_RELAY_VERSION={PINNED_NEMO_RELAY_CLI_VERSION}" in result assert "--install-dir /usr/local/bin" in result assert "nemo-relay --version" in result @@ -427,11 +434,11 @@ class TestRenderFabricDockerfile: """Tests for the public Fabric Dockerfile renderer.""" def test_config_only_mode(self, fabric_agent_config: Path) -> None: - from nemo_agents_plugin.container.template import render_fabric_dockerfile + from nemo_agents_plugin.container.template import get_contract_version, render_fabric_dockerfile result = render_fabric_dockerfile(fabric_agent_config) - assert 'uv pip install "nemo-platform[nemo-agents-plugin]==' in result + assert f'uv pip install "nemo-platform[nemo-agents-plugin]=={get_contract_version()}"' in result install_line = next(line for line in result.splitlines() if "uv pip install" in line) assert "--prerelease" not in install_line assert '" .' not in install_line @@ -468,7 +475,7 @@ def test_renders_platform_agent_oci_labels(self, tmp_path: Path) -> None: assert f'com.nemo.agent.contract-version="{get_contract_version()}"' in result def test_project_mode_preserves_relative_config_path(self, tmp_path: Path) -> None: - from nemo_agents_plugin.container.template import render_fabric_dockerfile + from nemo_agents_plugin.container.template import get_contract_version, render_fabric_dockerfile configs = tmp_path / "configs" configs.mkdir() @@ -479,12 +486,24 @@ def test_project_mode_preserves_relative_config_path(self, tmp_path: Path) -> No result = render_fabric_dockerfile(agent_config, pyproject) - assert 'uv pip install "nemo-platform[nemo-agents-plugin]==' in result + assert f'uv pip install "nemo-platform[nemo-agents-plugin]=={get_contract_version()}" .' in result install_line = next(line for line in result.splitlines() if "uv pip install" in line) assert "--prerelease" not in install_line assert '" .' in install_line assert "ENV AGENT_CONFIG_PATH=/workspace/configs/agent.yaml" in result + def test_unresolved_contract_version_is_rejected( + self, + fabric_agent_config: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + import nemo_agents_plugin.container.template as template + + monkeypatch.setattr(template, "get_contract_version", lambda: "0.0.0") + + with pytest.raises(ValueError, match="Unable to resolve the installed nemo-platform contract version"): + template.render_fabric_dockerfile(fabric_agent_config) + def test_custom_template_uses_fabric_context(self, tmp_path: Path, fabric_agent_config: Path) -> None: from nemo_agents_plugin.container.template import render_fabric_dockerfile @@ -1184,6 +1203,17 @@ def test_rejects_invalid_config(self, tmp_path: Path, content: str, error: str) with pytest.raises(ValueError, match=error): detect_agent_config_format(config) + def test_wraps_read_errors(self, tmp_path: Path) -> None: + from nemo_agents_plugin.container.builder import detect_agent_config_format + + with pytest.raises(ValueError, match="Unable to read config file"): + detect_agent_config_format(tmp_path / "missing.yaml") + + binary = tmp_path / "binary.yaml" + binary.write_bytes(b"\x80\x81\x82") + with pytest.raises(ValueError, match="Config file is not valid UTF-8"): + detect_agent_config_format(binary) + class TestBuildAgentImage: @patch("nemo_agents_plugin.container.builder.docker_build") From 19dde2165dcf2838cac694c87ceb69e93db9e243 Mon Sep 17 00:00:00 2001 From: Manjesh Mogallapalli Date: Mon, 3 Aug 2026 10:25:49 -0500 Subject: [PATCH 13/13] update docstring Signed-off-by: Manjesh Mogallapalli --- .../nemo-agents/src/nemo_agents_plugin/container/builder.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py b/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py index a907e36d81..426d71347d 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/container/builder.py @@ -4,7 +4,8 @@ """Docker image builder for NeMo Platform 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_nat_dockerfile`. +one on-the-fly via :func:`~nemo_agents_plugin.container.template.render_nat_dockerfile` +or :func:`~nemo_agents_plugin.container.template.render_fabric_dockerfile`. Uses `python-on-whales `_ for Docker operations so callers never need to shell out manually.