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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7,040 changes: 0 additions & 7,040 deletions docs/cli/reference.mdx

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/nemo_platform/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,7 @@ anonymizer = "nemo_anonymizer_plugin.skills:get_skills_path"
guardrails = "nemo_guardrails_plugin.skills:get_skills_path"
platform = "nemo_platform.skills:skills_dir"
safe-synthesizer = "nemo_safe_synthesizer_plugin.skills:get_skills_path"

[tool.uv.sources]
nemo-platform-sdk = { workspace = true }
nemo-platform-ext = { workspace = true }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@
PlatformJobResponse as PlatformJob,
)
from nemo_platform_plugin.schema import DatetimeFilter, Filter, Page, PaginationData, StringFilter
from pydantic import BaseModel, Field, TypeAdapter, field_validator
from collections.abc import Awaitable, Callable
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, field_validator, model_validator
from typing_extensions import Self

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -464,8 +466,16 @@ class JobRouteOption(StrEnum):


class PlatformJobResultRoute(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
name: str
serializer: ResultSerializer
serializer: ResultSerializer | None = None
handler: Callable[..., Awaitable[Response]] | None = None

@model_validator(mode="after")
def _require_handler_or_serializer(self) -> Self:
if self.handler is None and self.serializer is None:
raise ValueError("PlatformJobResultRoute requires either 'serializer' or 'handler'")
return self


# Compiler types: compiler receives both input spec (user-provided) and output spec (with auto-generated fields)
Expand Down Expand Up @@ -1233,12 +1243,21 @@ async def route(
return route

for job_result_route in job_result_routes:
router.add_api_route(
name=f"download_job_result_{job_result_route.name}",
path=f"/jobs/{{job}}/results/{job_result_route.name}/download",
endpoint=_stamp(_make_explicit_download_endpoint(job_result_route), perm="read", write=False),
**job_result_route.serializer.route_kwargs(),
)
if job_result_route.handler is not None:
router.add_api_route(
name=f"download_job_result_{job_result_route.name}",
path=f"/jobs/{{job}}/results/{job_result_route.name}/download",
endpoint=_stamp(job_result_route.handler, perm="read", write=False),
methods=["GET"],
response_class=FileResponse,
)
else:
router.add_api_route(
name=f"download_job_result_{job_result_route.name}",
path=f"/jobs/{{job}}/results/{job_result_route.name}/download",
endpoint=_stamp(_make_explicit_download_endpoint(job_result_route), perm="read", write=False),
**job_result_route.serializer.route_kwargs(), # type: ignore[union-attr]
)

# Add one final route for wildcard `{name}`, for undeclared results.
# This route will simply return the result's artifact as a file.
Expand Down
57 changes: 57 additions & 0 deletions plugins/nemo-auditor/src/nemo_auditor/jobs/artifacts_route.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""On-the-fly aggregate artifact download handler for audit jobs."""

from __future__ import annotations

import shutil
import tarfile
import tempfile
from pathlib import Path

from fastapi import BackgroundTasks, Depends, HTTPException, Response
from fastapi.responses import FileResponse
from nemo_platform import AsyncNeMoPlatform
from nemo_platform_plugin.client.adapter import client_from_platform
from nemo_platform_plugin.dependencies import get_sdk_client
from nemo_platform_plugin.jobs.client import AsyncJobsClient
from nemo_platform_plugin.jobs.result_manager import download_from_result_info

from nemo_auditor.jobs.audit import GARAK_RESULT_NAMES


async def aggregate_artifacts_download(
workspace: str,
job: str,
background_tasks: BackgroundTasks,
sdk: AsyncNeMoPlatform = Depends(get_sdk_client),
) -> Response:
"""Download all available Garak report results and return them as a single tar.gz."""
jobs_client = client_from_platform(sdk, AsyncJobsClient)
all_results = (await jobs_client.list_job_results(name=job, workspace=workspace)).data()
relevant = [r for r in all_results.data if r.name in GARAK_RESULT_NAMES]

if not relevant:
raise HTTPException(status_code=404, detail="No artifact results found for this audit job")

tmp_dirs = []
for result in relevant:
filename, tmp_dir_path = await download_from_result_info(
result_name=result.name,
job_name=job,
workspace=workspace,
artifact_url=result.artifact_url,
files_sdk=sdk,
)
tmp_dirs.append((filename, tmp_dir_path))
background_tasks.add_task(tmp_dir_path.cleanup_tmp_dir)

agg_tmp = Path(tempfile.mkdtemp())
tar_path = agg_tmp / "artifacts.tar.gz"
with tarfile.open(tar_path, "w:gz") as tar:
for filename, tmp_dir_path in tmp_dirs:
tar.add(tmp_dir_path.path, arcname=filename)
background_tasks.add_task(lambda: shutil.rmtree(agg_tmp, ignore_errors=True))
Comment on lines +38 to +55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Clean up temporary directories on failure.

If download_from_result_info or tar.add raises, the endpoint returns no FileResponse. FastAPI does not run these scheduled background tasks. The successful downloads then leak temporary directories.

Use try/except to clean accumulated result directories and agg_tmp synchronously before re-raising. Schedule background cleanup only after archive creation succeeds. Add a failed-download test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/nemo-auditor/src/nemo_auditor/jobs/artifacts_route.py` around lines
38 - 55, Update the artifact aggregation flow around download_from_result_info
and tar.add to synchronously clean all accumulated temporary result directories
and agg_tmp when either operation fails, then re-raise the exception. Schedule
background cleanup tasks only after the archive is created successfully, and add
a test covering cleanup after a failed download.


return FileResponse(path=tar_path, media_type="application/gzip", filename="artifacts.tar.gz")
2 changes: 2 additions & 0 deletions plugins/nemo-auditor/src/nemo_auditor/jobs/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@
("report-hitlog-jsonl", ".hitlog.jsonl"),
)

GARAK_RESULT_NAMES: frozenset[str] = frozenset(name for name, _ in _GARAK_OUTPUT_TYPES)

# garak refuses to start unless these are set even when unused (e.g. when
# the actual creds come through IGW). services/auditor sets the same four.
_REQUIRED_API_KEY_VARS = (
Expand Down
10 changes: 9 additions & 1 deletion plugins/nemo-auditor/src/nemo_auditor/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
from fastapi import APIRouter
from nemo_auditor.authz import scope
from nemo_auditor.jobs.audit import AuditJob
from nemo_auditor.jobs.artifacts_route import aggregate_artifacts_download
from nemo_platform_plugin.authz import CallerKind, path_rule
from nemo_platform_plugin.jobs.api_factory import PlatformJobResultRoute
from nemo_platform_plugin.jobs.routes import add_job_routes
from nemo_platform_plugin.service import NemoService, RouterSpec

Expand Down Expand Up @@ -58,7 +60,13 @@ async def healthz() -> dict[str, object]:
prefix=crud_prefix,
),
RouterSpec(
add_job_routes(AuditJob, authz=scope.child("audit")),
add_job_routes(
AuditJob,
authz=scope.child("audit"),
job_result_routes=[
PlatformJobResultRoute(name="artifacts", handler=aggregate_artifacts_download),
],
),
tag="Auditor Jobs",
description="Audit job submission and retrieval.",
prefix=crud_prefix,
Expand Down
171 changes: 171 additions & 0 deletions plugins/nemo-auditor/tests/test_artifacts_route.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Unit tests for the on-the-fly aggregate artifacts download handler."""

from __future__ import annotations

import io
import tarfile
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from fastapi import HTTPException
from fastapi.responses import FileResponse
from nemo_auditor.jobs.artifacts_route import aggregate_artifacts_download
from nemo_platform_plugin.jobs.file_manager import TmpDirPath


def _make_result(name: str, artifact_url: str = "file:///fake") -> MagicMock:
r = MagicMock()
r.name = name
r.artifact_url = artifact_url
return r


def _make_results_page(*names: str) -> MagicMock:
page = MagicMock()
page.data = [_make_result(n) for n in names]
return page


def _make_tmp_file(tmp_path: Path, name: str, content: bytes = b"data") -> TmpDirPath:
tmp_dir = Path(tempfile.mkdtemp(dir=tmp_path))
file_path = tmp_dir / name
file_path.write_bytes(content)
return TmpDirPath(path=file_path, tmp_dir=tmp_dir)


@dataclass
class _FakeSdk:
pass


def _make_jobs_client_mock(results_page: Any) -> MagicMock:
client = MagicMock()
list_resp = MagicMock()
list_resp.data.return_value = results_page
client.list_job_results = AsyncMock(return_value=list_resp)
return client


@pytest.mark.asyncio
class TestAggregateArtifactsDownload:
async def test_happy_path_returns_tar_with_all_results(self, tmp_path: Path) -> None:
results = _make_results_page("report-html", "report-jsonl")
html_tmp = _make_tmp_file(tmp_path, "report-html", b"<html/>")
jsonl_tmp = _make_tmp_file(tmp_path, "report-jsonl", b'{"probe":"x"}')

download_side_effects = [
("report-html", html_tmp),
("report-jsonl", jsonl_tmp),
]

background_tasks = MagicMock()
sdk = _FakeSdk()

with (
patch(
"nemo_auditor.jobs.artifacts_route.client_from_platform",
return_value=_make_jobs_client_mock(results),
),
patch(
"nemo_auditor.jobs.artifacts_route.download_from_result_info",
new=AsyncMock(side_effect=download_side_effects),
),
):
response = await aggregate_artifacts_download(
workspace="default",
job="audit-job-123",
background_tasks=background_tasks,
sdk=sdk, # type: ignore[arg-type]
)

assert isinstance(response, FileResponse)
tar_path = Path(response.path)
assert tar_path.exists()
with tarfile.open(tar_path, "r:gz") as tar:
members = {m.name for m in tar.getmembers()}
assert "report-html" in members
assert "report-jsonl" in members
# cleanup scheduled for both individual tmp dirs + aggregate dir
assert background_tasks.add_task.call_count == 3

async def test_partial_results_skips_missing(self, tmp_path: Path) -> None:
# Only report-html present; report-jsonl and report-hitlog-jsonl absent
results = _make_results_page("report-html")
html_tmp = _make_tmp_file(tmp_path, "report-html", b"<html/>")

background_tasks = MagicMock()
sdk = _FakeSdk()

with (
patch(
"nemo_auditor.jobs.artifacts_route.client_from_platform",
return_value=_make_jobs_client_mock(results),
),
patch(
"nemo_auditor.jobs.artifacts_route.download_from_result_info",
new=AsyncMock(return_value=("report-html", html_tmp)),
),
):
response = await aggregate_artifacts_download(
workspace="default",
job="audit-job-456",
background_tasks=background_tasks,
sdk=sdk, # type: ignore[arg-type]
)

assert isinstance(response, FileResponse)
with tarfile.open(Path(response.path), "r:gz") as tar:
members = {m.name for m in tar.getmembers()}
assert members == {"report-html"}

async def test_no_relevant_results_raises_404(self) -> None:
# Job has a result, but not one of the known Garak names
results = _make_results_page("some-other-result")

background_tasks = MagicMock()
sdk = _FakeSdk()

with (
patch(
"nemo_auditor.jobs.artifacts_route.client_from_platform",
return_value=_make_jobs_client_mock(results),
),
):
with pytest.raises(HTTPException) as exc_info:
await aggregate_artifacts_download(
workspace="default",
job="audit-job-789",
background_tasks=background_tasks,
sdk=sdk, # type: ignore[arg-type]
)

assert exc_info.value.status_code == 404

async def test_empty_results_raises_404(self) -> None:
results = _make_results_page()

background_tasks = MagicMock()
sdk = _FakeSdk()

with (
patch(
"nemo_auditor.jobs.artifacts_route.client_from_platform",
return_value=_make_jobs_client_mock(results),
),
):
with pytest.raises(HTTPException) as exc_info:
await aggregate_artifacts_download(
workspace="default",
job="audit-job-000",
background_tasks=background_tasks,
sdk=sdk, # type: ignore[arg-type]
)

assert exc_info.value.status_code == 404
11 changes: 8 additions & 3 deletions plugins/nemo-auditor/tests/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,20 @@
from nemo_platform_plugin.scheduler import submit_path_for


def _mounted_post_paths() -> set[str]:
def _mounted_paths_by_method(method: str) -> set[str]:
service = AuditorPluginService()
paths: set[str] = set()
for spec in service.get_routers():
for route in spec.router.routes:
if isinstance(route, APIRoute) and "POST" in route.methods:
if isinstance(route, APIRoute) and method in route.methods:
paths.add(f"/apis/auditor{spec.prefix}{route.path}")
return paths


def test_audit_job_submit_route_is_mounted() -> None:
assert submit_path_for(AuditJob, workspace="{workspace}") in _mounted_post_paths()
assert submit_path_for(AuditJob, workspace="{workspace}") in _mounted_paths_by_method("POST")


def test_audit_job_artifacts_download_route_is_mounted() -> None:
get_paths = _mounted_paths_by_method("GET")
assert "/apis/auditor/v2/workspaces/{workspace}/jobs/audit/{job}/results/artifacts/download" in get_paths
Loading