state-hub/api/routers/sbom.py
tegwick b75234a533
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 24s
feat: route SBOM writes to Nexus behind flag
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a028f0-a42f-7582-89a8-ebaad7343834
2026-08-22 19:55:08 +02:00

417 lines
14 KiB
Python

import uuid
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.managed_repo import ManagedRepo
from api.models.sbom_entry import Ecosystem, SBOMEntry
from api.models.sbom_snapshot import SBOMSnapshot
from api.schemas.sbom import (
LicenceGroup,
LicenceReport,
SBOMEntryRead,
SBOMIngest,
SBOMRepoView,
SBOMSnapshotDetail,
SBOMSnapshotRead,
)
from api.services.legacy_meter import identity_from_request, record_legacy_usage
from api.services.sbom_nexus import (
SBOMNexusError,
get_json,
post_json,
reads_from_nexus,
writes_to_nexus,
)
router = APIRouter(prefix="/sbom", tags=["sbom"])
logger = logging.getLogger(__name__)
_COPYLEFT_PATTERNS = {"GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL"}
def _is_copyleft(spdx: str | None) -> bool:
if not spdx:
return False
upper = spdx.upper()
return any(pat in upper for pat in _COPYLEFT_PATTERNS)
def _latest_snapshot_ids_subquery():
"""Subquery returning the latest SBOMSnapshot.id per repo."""
max_at_sq = (
select(SBOMSnapshot.repo_id, func.max(SBOMSnapshot.snapshot_at).label("max_at"))
.group_by(SBOMSnapshot.repo_id)
.subquery("max_snap_at")
)
return (
select(SBOMSnapshot.id)
.join(
max_at_sq,
and_(
SBOMSnapshot.repo_id == max_at_sq.c.repo_id,
SBOMSnapshot.snapshot_at == max_at_sq.c.max_at,
),
)
.subquery("latest_snap_ids")
)
@router.post("/ingest/")
async def ingest_sbom(
body: SBOMIngest,
request: Request,
session: AsyncSession = Depends(get_session),
) -> dict:
"""Create a new SBOM snapshot for a repo. Previous snapshots are retained."""
repo = await _get_repo_by_slug(body.repo_slug, session)
if writes_to_nexus():
payload = await _nexus_post("/sbom/ingest/", body=body.model_dump(mode="json"))
try:
snapshot_at = datetime.fromisoformat(
payload["snapshot_at"].replace("Z", "+00:00")
)
result = {
"repo_slug": payload["repo_slug"],
"snapshot_id": payload["snapshot_id"],
"ingested": payload["ingested"],
"snapshot_at": payload["snapshot_at"],
}
except (AttributeError, KeyError, TypeError, ValueError) as exc:
raise HTTPException(
status_code=502,
detail="SBOM Nexus returned an invalid ingest response",
) from exc
repo.last_sbom_at = snapshot_at
repo.sbom_source = "sbom-nexus"
await session.commit()
await _meter_compat(session, request, "POST", "/sbom/ingest/")
return result
now = datetime.now(tz=timezone.utc)
snap = SBOMSnapshot(
repo_id=repo.id,
snapshot_at=now,
source="manual",
entry_count=len(body.entries),
created_at=now,
)
session.add(snap)
await session.flush() # materialise snap.id before creating entries
for entry in body.entries:
sbom = SBOMEntry(
repo_id=repo.id,
snapshot_id=snap.id,
package_name=entry.package_name,
package_version=entry.package_version,
ecosystem=entry.ecosystem,
license_spdx=entry.license_spdx,
is_direct=entry.is_direct,
is_dev=entry.is_dev,
snapshot_at=now,
created_at=now,
)
session.add(sbom)
repo.last_sbom_at = now
if not repo.sbom_source:
repo.sbom_source = "manual"
await session.commit()
await _meter_compat(session, request, "POST", "/sbom/ingest/")
return {
"repo_slug": body.repo_slug,
"snapshot_id": str(snap.id),
"ingested": len(body.entries),
"snapshot_at": now.isoformat(),
}
@router.get("/snapshots/", response_model=list[SBOMSnapshotRead])
async def list_snapshots(
request: Request,
repo_slug: str | None = Query(None),
session: AsyncSession = Depends(get_session),
) -> list[SBOMSnapshotRead]:
"""List SBOM snapshots, newest first. Optionally filter by repo."""
await _meter_compat(session, request, "GET", "/sbom/snapshots/")
if reads_from_nexus():
payload = await _nexus_get(
"/sbom/snapshots/",
params={"repo_slug": repo_slug} if repo_slug else None,
)
return await _translate_snapshots(payload, session)
q = select(SBOMSnapshot).order_by(SBOMSnapshot.snapshot_at.desc())
if repo_slug:
repo = await _get_repo_by_slug(repo_slug, session)
q = q.where(SBOMSnapshot.repo_id == repo.id)
result = await session.execute(q)
return [SBOMSnapshotRead.model_validate(s) for s in result.scalars().all()]
@router.get("/snapshots/{snapshot_id}", response_model=SBOMSnapshotDetail)
async def get_snapshot(
snapshot_id: uuid.UUID,
request: Request,
session: AsyncSession = Depends(get_session),
) -> SBOMSnapshotDetail:
"""Get a snapshot with its full entry list."""
await _meter_compat(session, request, "GET", "/sbom/snapshots/{snapshot_id}")
if reads_from_nexus():
payload = await _nexus_get(f"/sbom/snapshots/{snapshot_id}")
repo_ids = await _local_repo_ids([payload], session)
repo_id = repo_ids[payload["repo_slug"]]
translated = _translate_snapshot(payload, repo_id)
translated["entries"] = [
_translate_entry(entry, repo_id) for entry in payload.get("entries", [])
]
return SBOMSnapshotDetail.model_validate(translated)
snap = await session.get(SBOMSnapshot, snapshot_id)
if snap is None:
raise HTTPException(status_code=404, detail=f"Snapshot '{snapshot_id}' not found")
result = await session.execute(
select(SBOMEntry)
.where(SBOMEntry.snapshot_id == snapshot_id)
.order_by(SBOMEntry.package_name)
)
entries = list(result.scalars().all())
return SBOMSnapshotDetail(
id=snap.id,
repo_id=snap.repo_id,
snapshot_at=snap.snapshot_at,
source=snap.source,
entry_count=snap.entry_count,
created_at=snap.created_at,
entries=[SBOMEntryRead.model_validate(e) for e in entries],
)
@router.get("/")
async def list_sbom_entries(
request: Request,
repo_slug: str | None = Query(None),
ecosystem: Ecosystem | None = Query(None),
license_spdx: str | None = Query(None),
is_direct: bool | None = Query(None),
is_dev: bool | None = Query(None),
session: AsyncSession = Depends(get_session),
) -> list[SBOMEntryRead]:
"""Return entries from the latest snapshot per repo (default) or filter by repo."""
await _meter_compat(session, request, "GET", "/sbom/")
if reads_from_nexus():
params = {
key: value
for key, value in {
"repo_slug": repo_slug,
"ecosystem": ecosystem.value if ecosystem is not None else None,
"license_spdx": license_spdx,
"is_direct": is_direct,
"is_dev": is_dev,
}.items()
if value is not None
}
payload = await _nexus_get("/sbom/", params=params)
repo_ids = await _local_repo_ids(payload, session)
return [
SBOMEntryRead.model_validate(_translate_entry(entry, repo_ids[entry["repo_slug"]]))
for entry in payload
]
if repo_slug:
repo = await _get_repo_by_slug(repo_slug, session)
latest_snap_id_sq = (
select(SBOMSnapshot.id)
.where(SBOMSnapshot.repo_id == repo.id)
.order_by(SBOMSnapshot.snapshot_at.desc())
.limit(1)
.scalar_subquery()
)
q = select(SBOMEntry).where(SBOMEntry.snapshot_id == latest_snap_id_sq)
else:
latest_ids_sq = _latest_snapshot_ids_subquery()
q = select(SBOMEntry).where(SBOMEntry.snapshot_id.in_(select(latest_ids_sq.c.id)))
if ecosystem is not None:
q = q.where(SBOMEntry.ecosystem == ecosystem)
if license_spdx:
q = q.where(SBOMEntry.license_spdx == license_spdx)
if is_direct is not None:
q = q.where(SBOMEntry.is_direct == is_direct)
if is_dev is not None:
q = q.where(SBOMEntry.is_dev == is_dev)
q = q.order_by(SBOMEntry.package_name)
result = await session.execute(q)
return [SBOMEntryRead.model_validate(e) for e in result.scalars().all()]
@router.get("/report/licences/", response_model=LicenceReport)
async def licence_report(
request: Request,
session: AsyncSession = Depends(get_session),
) -> LicenceReport:
"""Group latest-snapshot SBOM entries by SPDX licence identifier, flag copyleft."""
await _meter_compat(session, request, "GET", "/sbom/report/licences/")
if reads_from_nexus():
payload = await _nexus_get("/sbom/report/licences/")
return LicenceReport.model_validate(payload)
latest_ids_sq = _latest_snapshot_ids_subquery()
rows = await session.execute(
select(SBOMEntry, ManagedRepo.slug)
.join(ManagedRepo, ManagedRepo.id == SBOMEntry.repo_id)
.where(SBOMEntry.snapshot_id.in_(select(latest_ids_sq.c.id)))
)
groups: dict[str | None, dict] = {}
copyleft_direct_count = 0
for entry, repo_slug in rows.all():
key = entry.license_spdx
if key not in groups:
groups[key] = {"count": 0, "repos": set()}
groups[key]["count"] += 1
groups[key]["repos"].add(repo_slug)
if _is_copyleft(key) and entry.is_direct and not entry.is_dev:
copyleft_direct_count += 1
licence_groups = [
LicenceGroup(
license_spdx=lic,
count=info["count"],
repos=sorted(info["repos"]),
is_copyleft=_is_copyleft(lic),
)
for lic, info in sorted(groups.items(), key=lambda x: -x[1]["count"])
]
return LicenceReport(groups=licence_groups, copyleft_direct_count=copyleft_direct_count)
@router.get("/{repo_slug}", response_model=SBOMRepoView)
async def get_repo_sbom(
repo_slug: str,
request: Request,
session: AsyncSession = Depends(get_session),
) -> SBOMRepoView:
"""Return the latest snapshot entries for a specific repo."""
repo = await _get_repo_by_slug(repo_slug, session)
await _meter_compat(session, request, "GET", "/sbom/{repo_slug}")
if reads_from_nexus():
payload = await _nexus_get(f"/sbom/{repo_slug}")
payload["entries"] = [
_translate_entry(entry, repo.id) for entry in payload.get("entries", [])
]
return SBOMRepoView.model_validate(payload)
latest_snap_id_sq = (
select(SBOMSnapshot.id)
.where(SBOMSnapshot.repo_id == repo.id)
.order_by(SBOMSnapshot.snapshot_at.desc())
.limit(1)
.scalar_subquery()
)
rows = await session.execute(
select(SBOMEntry)
.where(SBOMEntry.snapshot_id == latest_snap_id_sq)
.order_by(SBOMEntry.package_name)
)
entries = list(rows.scalars().all())
return SBOMRepoView(
repo_slug=repo_slug,
last_sbom_at=repo.last_sbom_at,
entry_count=len(entries),
entries=[SBOMEntryRead.model_validate(e) for e in entries],
)
async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
repo = result.scalar_one_or_none()
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
return repo
async def _nexus_get(path: str, *, params: dict | None = None):
try:
return await get_json(path, params=params)
except SBOMNexusError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
async def _nexus_post(path: str, *, body: dict):
try:
return await post_json(path, body=body)
except SBOMNexusError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
async def _local_repo_ids(items: list[dict], session: AsyncSession) -> dict[str, uuid.UUID]:
slugs = {item.get("repo_slug") for item in items}
if None in slugs:
raise HTTPException(status_code=502, detail="SBOM Nexus response omitted repo_slug")
result = await session.execute(
select(ManagedRepo.slug, ManagedRepo.id).where(ManagedRepo.slug.in_(slugs))
)
repo_ids = dict(result.all())
missing = sorted(slugs - repo_ids.keys())
if missing:
raise HTTPException(
status_code=502,
detail=f"SBOM Nexus returned repositories absent from State Hub: {', '.join(missing)}",
)
return repo_ids
async def _translate_snapshots(
items: list[dict], session: AsyncSession
) -> list[SBOMSnapshotRead]:
repo_ids = await _local_repo_ids(items, session)
return [
SBOMSnapshotRead.model_validate(_translate_snapshot(item, repo_ids[item["repo_slug"]]))
for item in items
]
def _translate_snapshot(item: dict, repo_id: uuid.UUID) -> dict:
translated = {**item, "repo_id": repo_id}
source = translated.get("source")
if (
translated.get("legacy_id")
and isinstance(source, str)
and source.startswith("state-hub:")
):
translated["source"] = source.removeprefix("state-hub:")
return translated
def _translate_entry(item: dict, repo_id: uuid.UUID) -> dict:
return {**item, "repo_id": repo_id}
async def _meter_compat(
session: AsyncSession,
request: Request,
method: str,
route: str,
) -> None:
"""Meter the compatibility surface without making route success depend on it."""
interface_key = f"rest_api:{method} {route}"
try:
await record_legacy_usage(
session,
interface_key=interface_key,
interface_kind="rest_api",
replacement_ref="sbom-nexus:/sbom/",
owner_component="state-hub.sbom-compat",
replacement_verified=reads_from_nexus(),
identity=identity_from_request(request),
)
except Exception:
await session.rollback()
logger.warning("legacy-meter failed to record %s", interface_key, exc_info=True)