feat: project Nexus SBOM state into summaries
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a028f0-a42f-7582-89a8-ebaad7343834
This commit is contained in:
tegwick 2026-08-22 20:12:16 +02:00
parent e1e259cf87
commit 76e6eda086
4 changed files with 172 additions and 42 deletions

View file

@ -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

View file

@ -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(

View file

@ -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
return merged

View file

@ -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