feat: project Nexus SBOM state into summaries
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a028f0-a42f-7582-89a8-ebaad7343834
This commit is contained in:
parent
e1e259cf87
commit
76e6eda086
4 changed files with 172 additions and 42 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue