From 76e6eda0863509688274337eb6a32e44187ec9e4 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 22 Aug 2026 20:12:16 +0200 Subject: [PATCH] feat: project Nexus SBOM state into summaries Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a028f0-a42f-7582-89a8-ebaad7343834 --- api/routers/repos.py | 67 +++++++++++++++++++++++++++++-- api/routers/state.py | 71 +++++++++++++++++---------------- api/services/summary_cache.py | 18 +++++++-- tests/test_sbom_nexus_compat.py | 58 +++++++++++++++++++++++++++ 4 files changed, 172 insertions(+), 42 deletions(-) diff --git a/api/routers/repos.py b/api/routers/repos.py index c9c9e9f..8f9b919 100644 --- a/api/routers/repos.py +++ b/api/routers/repos.py @@ -48,6 +48,9 @@ from api.schemas.managed_repo import ( classification_fields_set, validate_repo_classification_fields, ) +from api.services.sbom_nexus import SBOMNexusError +from api.services.sbom_nexus import get_json as get_sbom_nexus_json +from api.services.sbom_nexus import reads_from_nexus from hub_core.routers.repos import create_repos_router router = APIRouter(prefix="/repos", tags=["repos"]) @@ -108,7 +111,7 @@ async def list_repos( capability_tag: str | None = None, business_stake: str | None = None, session: AsyncSession = Depends(get_session), -) -> list[ManagedRepo]: +) -> list[RepoRead]: """List repos with optional domain and classification filters.""" response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30" q = ( @@ -134,7 +137,7 @@ async def list_repos( if business_stake: q = q.where(ManagedRepo.business_stake.contains([business_stake])) result = await session.execute(q) - return list(result.scalars().all()) + return await _project_repo_reads(list(result.scalars().all())) @router.post("/", response_model=RepoRead, status_code=status.HTTP_201_CREATED) @@ -330,6 +333,7 @@ async def doi_summary(session: AsyncSession = Depends(get_session)) -> list[DoIS select(ManagedRepo).where(ManagedRepo.status == "active").order_by(ManagedRepo.name) ) repos = list(repos_result.scalars().all()) + sbom_projections = await _sbom_projection_map() repo_ids = [r.id for r in repos] id_to_slug = {r.id: r.slug for r in repos} @@ -385,7 +389,7 @@ async def doi_summary(session: AsyncSession = Depends(get_session)) -> list[DoIS "local_path": repo.local_path, "remote_url": repo.remote_url, "host_paths": repo.host_paths or {}, - "last_sbom_at": str(repo.last_sbom_at) if repo.last_sbom_at else None, + "last_sbom_at": _projected_last_sbom_at(repo, sbom_projections), "updated_at": str(repo.updated_at) if repo.updated_at else "", } fp = compute_fingerprint( @@ -465,6 +469,7 @@ async def get_repo_doi( Results are cached by fingerprint. Pass ?force_refresh=true to bypass the cache. """ repo = await _get_repo_by_slug(slug, session) + sbom_projections = await _sbom_projection_map() domain_result = await session.execute(select(Domain).where(Domain.id == repo.domain_id)) domain_obj = domain_result.scalar_one_or_none() @@ -484,7 +489,7 @@ async def get_repo_doi( "local_path": repo.local_path, "remote_url": repo.remote_url, "host_paths": repo.host_paths or {}, - "last_sbom_at": str(repo.last_sbom_at) if repo.last_sbom_at else None, + "last_sbom_at": _projected_last_sbom_at(repo, sbom_projections), "updated_at": str(repo.updated_at) if repo.updated_at else "", } fp = compute_fingerprint(repo_dict, str(tpsc_row.latest) if tpsc_row.latest else None, @@ -622,6 +627,15 @@ async def update_repo_with_classification( return repo +@router.get("/{slug}", response_model=RepoRead) +async def get_repo_with_sbom_projection( + slug: str, + session: AsyncSession = Depends(get_session), +) -> RepoRead: + repo = await _get_repo_by_slug(slug, session) + return (await _project_repo_reads([repo]))[0] + + router.include_router( _core_repo_router( include_collection_routes=False, @@ -822,3 +836,48 @@ def _repo_doi_dict(repo: ManagedRepo, domain_slug: str | None) -> dict: "last_sbom_at": str(repo.last_sbom_at) if repo.last_sbom_at else None, "updated_at": str(repo.updated_at) if repo.updated_at else "", } + + +async def _sbom_projection_map() -> dict[str, datetime | None]: + if not reads_from_nexus(): + return {} + try: + repositories = await get_sbom_nexus_json("/repositories/") + except SBOMNexusError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + return { + repository["slug"]: ( + datetime.fromisoformat(repository["last_attempt_at"].replace("Z", "+00:00")) + if repository.get("last_attempt_at") + else None + ) + for repository in repositories + } + + +def _projected_last_sbom_at( + repo: ManagedRepo, + projections: dict[str, datetime | None], +) -> str | None: + if repo.slug in projections: + projected = projections[repo.slug] + return projected.isoformat() if projected else None + return str(repo.last_sbom_at) if repo.last_sbom_at else None + + +async def _project_repo_reads(repositories: list[ManagedRepo]) -> list[RepoRead]: + projections = await _sbom_projection_map() + result: list[RepoRead] = [] + for repository in repositories: + read = RepoRead.model_validate(repository) + if repository.slug in projections: + read = read.model_copy( + update={ + "last_sbom_at": projections[repository.slug], + "sbom_source": "sbom-nexus" + if projections[repository.slug] + else None, + } + ) + result.append(read) + return result diff --git a/api/routers/state.py b/api/routers/state.py index 5c0256f..db3d930 100644 --- a/api/routers/state.py +++ b/api/routers/state.py @@ -54,6 +54,8 @@ from api.services.summary_cache import ( register_summary_cache_invalidation, ) from api.services.ops_run_projection import get_ops_run_projection +from api.services.sbom_nexus import get_json as get_sbom_nexus_json +from api.services.sbom_nexus import reads_from_nexus def _dual_workplan_refs( @@ -364,23 +366,7 @@ async def build_state_summary(session: AsyncSession) -> StateSummary: )} contribution_counts = {**contrib_type_counts, **contrib_status_counts} - # Licence risk: copyleft packages in direct prod deps - _COPYLEFT_PATS = ("GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL") - copyleft_risk_rows = await session.execute( - select(func.count()).select_from(SBOMEntry) - .where(SBOMEntry.is_direct.is_(True)) - .where(SBOMEntry.is_dev.is_(False)) - ) - # Filter in Python since ILIKE across multiple patterns is verbose in SQLAlchemy - all_direct_prod_rows = await session.execute( - select(SBOMEntry.license_spdx) - .where(SBOMEntry.is_direct.is_(True)) - .where(SBOMEntry.is_dev.is_(False)) - ) - licence_risk_count = sum( - 1 for (lic,) in all_direct_prod_rows.all() - if lic and any(pat in lic.upper() for pat in _COPYLEFT_PATS) - ) + licence_risk_count, _, _ = await _sbom_metrics(session) # Open capability requests (non-terminal statuses) open_cap_req_count = (await session.execute( @@ -646,23 +632,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview: )} contribution_counts = {**contrib_type_counts, **contrib_status_counts} - _COPYLEFT_PATS = ("GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL") - all_direct_prod_rows = await session.execute( - select(SBOMEntry.license_spdx) - .where(SBOMEntry.is_direct.is_(True)) - .where(SBOMEntry.is_dev.is_(False)) - ) - licence_risk_count = sum( - 1 for (lic,) in all_direct_prod_rows.all() - if lic and any(pat in lic.upper() for pat in _COPYLEFT_PATS) - ) - - snapshot_count, package_total = (await session.execute( - select( - func.count(SBOMSnapshot.id), - func.coalesce(func.sum(SBOMSnapshot.entry_count), 0), - ) - )).one() + licence_risk_count, snapshot_count, package_total = await _sbom_metrics(session) open_cap_req_count = (await session.execute( select(func.count()).select_from(CapabilityRequest).where( @@ -742,6 +712,39 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview: ) +async def _sbom_metrics(session: AsyncSession) -> tuple[int, int, int]: + """Return compatibility summary metrics from the selected SBOM authority.""" + if reads_from_nexus(): + snapshots = await get_sbom_nexus_json("/sbom/snapshots/") + report = await get_sbom_nexus_json("/sbom/report/licences/") + return ( + int(report.get("copyleft_direct_count") or 0), + len(snapshots), + sum(int(snapshot.get("entry_count") or 0) for snapshot in snapshots), + ) + + copyleft_patterns = ("GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL") + rows = await session.execute( + select(SBOMEntry.license_spdx) + .where(SBOMEntry.is_direct.is_(True)) + .where(SBOMEntry.is_dev.is_(False)) + ) + licence_risk_count = sum( + 1 + for (licence,) in rows.all() + if licence and any(pattern in licence.upper() for pattern in copyleft_patterns) + ) + snapshot_count, package_total = ( + await session.execute( + select( + func.count(SBOMSnapshot.id), + func.coalesce(func.sum(SBOMSnapshot.entry_count), 0), + ) + ) + ).one() + return licence_risk_count, int(snapshot_count or 0), int(package_total or 0) + + async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]: """Compute per-domain stats for the state summary.""" domains_rows = await session.execute( diff --git a/api/services/summary_cache.py b/api/services/summary_cache.py index 4061f55..55c6e29 100644 --- a/api/services/summary_cache.py +++ b/api/services/summary_cache.py @@ -27,6 +27,8 @@ from api.models.workplan import Workplan from api.models.workplan_dependency import WorkplanDependency from api.schemas.progress_event import ProgressEventRead from api.schemas.state import StateSummary +from api.services.sbom_nexus import get_json as get_sbom_nexus_json +from api.services.sbom_nexus import reads_from_nexus logger = logging.getLogger(__name__) @@ -88,9 +90,17 @@ async def fetch_summary_revision(session: AsyncSession) -> SummaryRevision: if value is not None: core_parts.append(value) - sbom_at = ( - await session.execute(select(func.max(SBOMSnapshot.snapshot_at))) - ).scalar_one_or_none() + if reads_from_nexus(): + snapshots = await get_sbom_nexus_json("/sbom/snapshots/") + snapshot_times = [ + datetime.fromisoformat(item["snapshot_at"].replace("Z", "+00:00")) + for item in snapshots + ] + sbom_at = max(snapshot_times, default=None) + else: + sbom_at = ( + await session.execute(select(func.max(SBOMSnapshot.snapshot_at))) + ).scalar_one_or_none() progress_at = ( await session.execute(select(func.max(ProgressEvent.created_at))) @@ -285,4 +295,4 @@ async def apply_progress_section( cache._entry.progress_revision = revision.progress_fingerprint() else: cache.store(merged, revision) - return merged \ No newline at end of file + return merged diff --git a/tests/test_sbom_nexus_compat.py b/tests/test_sbom_nexus_compat.py index 953d508..018aa3b 100644 --- a/tests/test_sbom_nexus_compat.py +++ b/tests/test_sbom_nexus_compat.py @@ -220,3 +220,61 @@ async def test_nexus_failure_is_visible_and_does_not_fall_back(client, monkeypat assert response.status_code == 502 assert response.json()["detail"] == "SBOM Nexus is unavailable: ConnectError" + + +async def test_repo_reads_project_nexus_last_attempt_at(client, monkeypatch): + await _create_repo(client) + + async def repositories(path: str, **kwargs): + assert path == "/repositories/" + return [ + { + "slug": "testrepo", + "last_attempt_at": "2026-08-22T14:00:00Z", + } + ] + + monkeypatch.setattr(settings, "sbom_nexus_read_mode", "nexus") + monkeypatch.setattr("api.routers.repos.get_sbom_nexus_json", repositories) + + collection = await client.get("/repos/") + detail = await client.get("/repos/testrepo") + + assert collection.status_code == 200 + assert detail.status_code == 200 + assert collection.json()[0]["last_sbom_at"] == "2026-08-22T14:00:00Z" + assert collection.json()[0]["sbom_source"] == "sbom-nexus" + assert detail.json()["last_sbom_at"] == "2026-08-22T14:00:00Z" + assert detail.json()["sbom_source"] == "sbom-nexus" + + +async def test_state_summaries_use_nexus_metrics(client, monkeypatch): + async def sbom_metrics(path: str, **kwargs): + if path == "/sbom/snapshots/": + return [ + { + "snapshot_at": "2026-08-22T12:00:00Z", + "entry_count": 3, + }, + { + "snapshot_at": "2026-08-22T14:00:00Z", + "entry_count": 5, + }, + ] + if path == "/sbom/report/licences/": + return {"groups": [], "copyleft_direct_count": 7} + raise AssertionError(f"unexpected Nexus path {path}") + + monkeypatch.setattr(settings, "sbom_nexus_read_mode", "nexus") + monkeypatch.setattr("api.routers.state.get_sbom_nexus_json", sbom_metrics) + monkeypatch.setattr("api.services.summary_cache.get_sbom_nexus_json", sbom_metrics) + + summary = await client.get("/state/summary", params={"refresh": "true"}) + overview = await client.get("/state/overview", params={"refresh": "true"}) + + assert summary.status_code == 200 + assert overview.status_code == 200 + assert summary.json()["licence_risk_count"] == 7 + assert overview.json()["licence_risk_count"] == 7 + assert overview.json()["sbom_snapshot_count"] == 2 + assert overview.json()["sbom_package_total"] == 8