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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions plugins/nemo-auditor/openapi/openapi.yaml

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

107 changes: 107 additions & 0 deletions plugins/nemo-auditor/src/nemo_auditor/api/v2/artifacts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Aggregate artifact download for audit jobs.

Registered before the generic ``/{name}/download`` catch-all so FastAPI
matches this specific path first. Fetches the individual garak report
results already stored by the job and streams them back as a single
``artifacts.tar.gz`` — no additional storage.
"""

from __future__ import annotations

import logging
import tarfile
import tempfile
from pathlib import Path

import anyio.to_thread
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from fastapi.responses import FileResponse
from nemo_auditor.authz import scope
from nemo_platform import AsyncNeMoPlatform
from nemo_platform_plugin.authz import CallerKind, path_rule
from nemo_platform_plugin.client.adapter import client_from_platform
from nemo_platform_plugin.client.errors import NotFoundError
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 result_manager_factory

logger = logging.getLogger(__name__)

router = APIRouter()

_AUDIT_SCOPE = scope.child("audit")
_AUDIT_READ_PERMISSION = _AUDIT_SCOPE.permission(
"read",
description="Read auditor.audit jobs, including status, logs, and results",
)

# The individual result names published by AuditJob, in preferred archive order.
_REPORT_RESULT_NAMES = ("report-jsonl", "report-html", "report-hitlog-jsonl")


@router.get(
"/jobs/audit/{job}/results/artifacts/download",
response_class=FileResponse,
responses={
200: {
"description": "Aggregate gzip archive of all garak report artifacts.",
"content": {"application/gzip": {"schema": {"type": "string", "format": "binary"}}},
},
404: {"description": "No report artifacts found for the job."},
},
)
@_AUDIT_SCOPE.read
@path_rule(callers=[CallerKind.PRINCIPAL], permissions=[_AUDIT_READ_PERMISSION])
async def download_audit_artifacts(
workspace: str,
job: str,
background_tasks: BackgroundTasks,
sdk: AsyncNeMoPlatform = Depends(get_sdk_client),
) -> FileResponse:
"""Stream an aggregate tar.gz of all garak report artifacts for an audit job."""
jobs_client = client_from_platform(sdk, AsyncJobsClient)
result_manager = result_manager_factory(job_name=job, workspace=workspace, files_sdk=sdk)

tmp_dir = tempfile.TemporaryDirectory()
artifact_tmps = []
try:
tmp = Path(tmp_dir.name)

for result_name in _REPORT_RESULT_NAMES:
try:
result_info = (await jobs_client.get_job_result(name=result_name, job=job, workspace=workspace)).data()
except NotFoundError:
continue

artifact_tmps.append(await result_manager.download_artifact(artifact_url=result_info.artifact_url))

if not artifact_tmps:
raise HTTPException(
status_code=404,
detail=f"No report artifacts found for audit job '{job}' in workspace '{workspace}'.",
)

tar_path = tmp / "artifacts.tar.gz"

def _create_archive() -> None:
with tarfile.open(tar_path, "w:gz") as tar:
for artifact_tmp in artifact_tmps:
tar.add(artifact_tmp.path, arcname=artifact_tmp.path.name)

await anyio.to_thread.run_sync(_create_archive)

Comment thread
parkanzky marked this conversation as resolved.
def _cleanup():
for t in artifact_tmps:
t.cleanup_tmp_dir()
tmp_dir.cleanup()

background_tasks.add_task(_cleanup)
return FileResponse(path=str(tar_path), filename="artifacts.tar.gz", media_type="application/gzip")
except Exception:
for t in artifact_tmps:
t.cleanup_tmp_dir()
tmp_dir.cleanup()
raise
1 change: 0 additions & 1 deletion plugins/nemo-auditor/src/nemo_auditor/jobs/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -627,7 +627,6 @@ def _on_sigterm(signum, frame):
if has_reports
else {}
)

n_failed = len(list(failed_dir.iterdir()))
status = "completed" if n_failed == 0 else "partial"
self.report_progress(
Expand Down
8 changes: 7 additions & 1 deletion plugins/nemo-auditor/src/nemo_auditor/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class AuditorPluginService(NemoService):
dependencies: ClassVar[list[str]] = ["entities"]

def get_routers(self) -> list[RouterSpec]:
from nemo_auditor.api.v2 import configs, targets
from nemo_auditor.api.v2 import artifacts, configs, targets

healthz_router = APIRouter()

Expand Down Expand Up @@ -57,6 +57,12 @@ async def healthz() -> dict[str, object]:
description="Audit target CRUD.",
prefix=crud_prefix,
),
RouterSpec(
router=artifacts.router,
tag="Auditor Jobs",
description="Audit job aggregate artifact download.",
prefix=crud_prefix,
),
RouterSpec(
add_job_routes(AuditJob, authz=scope.child("audit")),
tag="Auditor Jobs",
Expand Down
Loading
Loading